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/app/build.gradle.kts b/app/build.gradle.kts index 530687a6d1..6e701b940a 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) @@ -164,6 +166,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) @@ -208,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) @@ -225,6 +229,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) @@ -235,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/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dcb214db57..d77020adb5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -313,6 +313,16 @@ + + + + + + + + @@ -337,6 +347,16 @@ android:host="yield" android:scheme="tangem" /> + + + + + + + + diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index f983c6defd..5559381cd9 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit f983c6defd0b2240eb6f134aefe30f7e93696795 +Subproject commit 5559381cd92d7d8747ca4531462879915499d405 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/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/di/domain/MarketingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt new file mode 100644 index 0000000000..16b6359490 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt @@ -0,0 +1,36 @@ +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 com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase +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) + + @Provides + @Singleton + fun provideWarmUpMarketingCampaignsUseCase( + repository: MarketingRepository, + featureToggles: MarketingFeatureToggles, + ): WarmUpMarketingCampaignsUseCase = WarmUpMarketingCampaignsUseCase(repository, featureToggles) +} \ No newline at end of file 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/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/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/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/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..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,10 +17,12 @@ 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 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 +59,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, @@ -64,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) @@ -173,11 +177,13 @@ 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) 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/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/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt index 6593737113..ae527a4ca6 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt @@ -3,13 +3,18 @@ package com.tangem.tap.domain.userWalletList.repository import com.google.common.truth.Truth.assertThat import com.tangem.common.CompletionResult import com.tangem.common.core.TangemError +import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase +import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.common.wallets.UserWalletSelectedHandler +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.hotwallet.repository.HotWalletRepository +import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository @@ -144,4 +149,162 @@ internal class DefaultUserWalletsListRepositoryTest { assertThat(result.isLeft()).isTrue() verify(exactly = 0) { trackingContextProxy.eraseContext() } } + + @Test + fun `GIVEN stale backup status WHEN duplicate save rejected THEN stored card state refreshed`() = runTest { + // Arrange + val storedWallet = MockUserWalletFactory.create(staleScanResponse) + val freshWallet = MockUserWalletFactory.create(freshScanResponse) + repository.userWallets.value = listOf(storedWallet) + coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit) + + // Act + val result = repository.saveWithoutLock(freshWallet, canOverride = false) + + // Assert + assertThat(result.isLeft()).isTrue() + val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold + assertThat(updatedWallet.scanResponse.card.backupStatus) + .isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1)) + assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue() + coVerify(exactly = 1) { publicInformationRepository.save(any(), true) } + } + + @Test + fun `GIVEN locked wallet with stale backup status WHEN unlock with scanned card THEN stored card state refreshed`() = + runTest { + // Arrange + val storedWallet = MockUserWalletFactory.create(staleScanResponse).let { wallet -> + wallet.copy( + scanResponse = wallet.scanResponse.copy( + card = wallet.scanResponse.card.copy(wallets = emptyList()), + ), + ) + } + repository.userWallets.value = listOf(storedWallet) + coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit) + coEvery { sensitiveInformationRepository.getAll(any()) } returns CompletionResult.Success(emptyMap()) + + // Act + val result = repository.unlock( + userWalletId = storedWallet.walletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan( + scanResponse = freshScanResponse, + source = AnalyticsParam.ScreensSources.SignIn, + ), + ) + + // Assert + assertThat(result.isRight()).isTrue() + val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold + assertThat(updatedWallet.scanResponse.card.backupStatus) + .isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1)) + assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue() + coVerify(exactly = 1) { publicInformationRepository.save(any(), true) } + } + + @Test + fun `GIVEN active card of backup set scanned WHEN duplicate save rejected THEN stored card state refreshed`() = + runTest { + // Arrange + val storedWallet = MockUserWalletFactory.create(staleScanResponse) + val otherCardScanResponse = freshScanResponse.copy( + card = freshScanResponse.card.copy(cardId = "OTHER-CARD"), + ) + val freshWallet = MockUserWalletFactory.create(otherCardScanResponse) + repository.userWallets.value = listOf(storedWallet) + coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit) + + // Act + val result = repository.saveWithoutLock(freshWallet, canOverride = false) + + // Assert + assertThat(result.isLeft()).isTrue() + val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold + assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId) + assertThat(updatedWallet.scanResponse.card.backupStatus) + .isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1)) + assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue() + coVerify(exactly = 1) { publicInformationRepository.save(any(), true) } + } + + @Test + fun `GIVEN no backup card of same wallet scanned WHEN duplicate save rejected THEN stored status downgraded`() = + runTest { + // Arrange + val storedWallet = MockUserWalletFactory.create(freshScanResponse) + val newCardScanResponse = staleScanResponse.copy( + card = staleScanResponse.card.copy(cardId = "SAME-SEED-NEW-CARD"), + ) + val freshWallet = MockUserWalletFactory.create(newCardScanResponse) + repository.userWallets.value = listOf(storedWallet) + coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit) + + // Act + val result = repository.saveWithoutLock(freshWallet, canOverride = false) + + // Assert + assertThat(result.isLeft()).isTrue() + val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold + assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId) + assertThat(updatedWallet.scanResponse.card.backupStatus).isEqualTo(CardDTO.BackupStatus.NoBackup) + assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isFalse() + coVerify(exactly = 1) { publicInformationRepository.save(any(), true) } + } + + @Test + fun `GIVEN stored card linked status WHEN duplicate save with another card rejected THEN status preserved`() = + runTest { + // Arrange + val cardLinkedScanResponse = staleScanResponse.copy( + card = staleScanResponse.card.copy(backupStatus = CardDTO.BackupStatus.CardLinked(cardCount = 1)), + ) + val storedWallet = MockUserWalletFactory.create(cardLinkedScanResponse) + val otherCardScanResponse = freshScanResponse.copy( + card = freshScanResponse.card.copy(cardId = "OTHER-CARD"), + ) + val freshWallet = MockUserWalletFactory.create(otherCardScanResponse) + repository.userWallets.value = listOf(storedWallet) + + // Act + val result = repository.saveWithoutLock(freshWallet, canOverride = false) + + // Assert + assertThat(result.isLeft()).isTrue() + assertThat(repository.userWallets.value).containsExactly(storedWallet) + coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) } + } + + @Test + fun `GIVEN stored card state is actual WHEN duplicate save rejected THEN nothing persisted`() = runTest { + // Arrange + val storedWallet = MockUserWalletFactory.create(freshScanResponse) + val freshWallet = MockUserWalletFactory.create(freshScanResponse) + repository.userWallets.value = listOf(storedWallet) + + // Act + val result = repository.saveWithoutLock(freshWallet, canOverride = false) + + // Assert + assertThat(result.isLeft()).isTrue() + assertThat(repository.userWallets.value).containsExactly(storedWallet) + coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) } + } + + private companion object { + + val staleScanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(maxWalletCount = 2), + derivedKeys = emptyMap(), + ).let { scanResponse -> + scanResponse.copy(card = scanResponse.card.copy(backupStatus = CardDTO.BackupStatus.NoBackup)) + } + + val freshScanResponse = staleScanResponse.copy( + card = staleScanResponse.card.copy( + backupStatus = CardDTO.BackupStatus.Active(cardCount = 1), + isAccessCodeSet = true, + ), + ) + } } \ No newline at end of file diff --git a/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..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,9 +16,11 @@ 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 +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 +86,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() } @@ -112,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() @@ -140,12 +150,14 @@ class DeepLinkFactoryTest { swapDeepLink = swapDeepLinkFactory, promoDeepLink = promoDeepLinkFactory, onboardVisaDeepLink = onboardVisaDeepLink, + onboardVirtualAccountsDeepLink = onboardVirtualAccountsDeepLink, tangemPayMainDeepLink = tangemPayMainDeepLink, newsDetailsDeepLink = newsDeeplink, newsDeepLink = newsDeepLinkFactory, earnDeepLink = earnDeepLinkFactory, yieldDeepLink = yieldDeepLinkFactory, surveyDeepLink = surveyDeepLinkFactory, + promoCampaignsDeepLink = campaignsDeepLinkHandlerFactory, ) @OptIn(ExperimentalCoroutinesApi::class) 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..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 @@ -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" } @@ -91,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/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/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..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 @@ -170,5 +170,17 @@ { "name": "AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED", "version": "6.0" + }, + { + "name": "TWI_1638_VA_MVP0_ENABLED", + "version": "6.0.1" + }, + { + "name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED", + "version": "6.0.1" + }, + { + "name": "TWI_1522_MARKETING_BANNERS_ENABLED", + "version": "6.0.1" } ] diff --git a/core/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/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 80fcf19f81..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 @@ -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, @@ -64,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 new file mode 100644 index 0000000000..b3564320f4 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt @@ -0,0 +1,24 @@ +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 = "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 da654f8d16..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, @@ -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/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..0b2d9523f2 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt @@ -0,0 +1,20 @@ +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, + @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 new file mode 100644 index 0000000000..8909098866 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt @@ -0,0 +1,26 @@ +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: 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/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/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..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 @@ -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 @@ -235,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/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/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/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..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 @@ -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 @@ -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/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/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/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/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/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 2d1d56553e..60e733758d 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -441,6 +441,7 @@ Rename Required Reset + Retry Save Save changes Search @@ -741,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 @@ -908,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 @@ -1930,9 +1934,35 @@ %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. @@ -2091,8 +2121,11 @@ 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 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/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/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..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 @@ -178,11 +178,7 @@ inline fun BasicModalBottomSheetWit val isKeyboardOpen by rememberIsKeyboardVisible() val buttonHeight by animateDpAsState( - if (footer != null) { - 80.dp - } else { - 0.dp - }, + targetValue = if (footer != null) 80.dp else 0.dp, ) // Offset calculation for keyboard scroll adjustment: // 1) Button height (footer) 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/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/ds2/messagebanner/TangemMessageBanner.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt new file mode 100644 index 0000000000..145f3188c6 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt @@ -0,0 +1,487 @@ +@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.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 +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.conditionalCompose +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. + * + * 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. + */ +@Composable +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) { + WithMessageBannerRipple(enabled = isClickable) { + TangemSurface( + modifier = Modifier.fillMaxWidth(), + color = tokens.background, + shape = RoundedCornerShape(28.dp), + ) { + 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) { + 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. + * + * 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. + * @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 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. + * @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, + onClick: (() -> Unit)? = null, + 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, + onClick = onClick, + 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 = 8.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 } + }, + ) +} + +/** 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) + +@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 = {}), + ) + TangemMessageBanner( + modifier = Modifier.fillMaxWidth(), + title = stringReference("Clickable banner"), + description = stringReference("Whole banner is tappable when no buttons are set."), + 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/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/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/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: 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..547f9bbc60 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt @@ -0,0 +1,144 @@ +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.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( + private val tangemTechApi: TangemTechApi, + private val cacheStore: MarketingCampaignsCacheStore, + private val dismissStore: MarketingDismissStore, + private val converter: MarketingCampaignConverter, + 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 { + 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() + } + } + } + } + + 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?, + ): 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, + -> requestByType(screen.type, eTag) + } + } + + 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..40895fc9a5 --- /dev/null +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt @@ -0,0 +1,262 @@ +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.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 +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() + + // 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( + 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 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 + 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 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 + 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/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/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..cfe29ea4d4 --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt @@ -0,0 +1,103 @@ +package com.tangem.data.promo + +import com.squareup.moshi.Moshi +import com.tangem.data.promo.converter.PromoCampaignConverter +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.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.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +internal class DefaultPromoRepository( + private val promotionsSupplier: PromotionsSupplier, + private val tangemApi: TangemTechApi, + private val moshi: Moshi, + private val dispatchers: CoroutineDispatcherProvider, +) : PromoRepository { + + override suspend fun getCampaignState( + campaign: PromoCampaignId, + userWalletId: UserWalletId, + forceRefresh: Boolean, + ): PromoCampaignState = withContext(dispatchers.io) { + 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() + 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.toEnrolledTokenReward() + 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, + userAddress = userAddress, + tokenId = tokenId, + ) + + 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 { + 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..91aec47f55 --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt @@ -0,0 +1,31 @@ +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( + tokenId = token.tokenId, + tokenAddress = token.tokenAddress, + tokenSymbol = token.tokenSymbol, + tokenName = token.tokenName, + networkId = token.networkId, + decimals = token.decimals, + ) + }, + 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..905c2597a7 --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt @@ -0,0 +1,35 @@ +package com.tangem.data.promo.di + +import com.squareup.moshi.Moshi +import com.tangem.data.promo.DefaultPromoRepository +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.di.NetworkMoshi +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 providePromoRepository( + promotionsSupplier: PromotionsSupplier, + tangemApi: TangemTechApi, + @NetworkMoshi moshi: Moshi, + dispatchers: CoroutineDispatcherProvider, + ): PromoRepository { + return DefaultPromoRepository( + promotionsSupplier = promotionsSupplier, + tangemApi = tangemApi, + moshi = moshi, + dispatchers = dispatchers, + ) + } +} \ 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..faa0e46217 --- /dev/null +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt @@ -0,0 +1,200 @@ +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.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +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.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.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 moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + + private val repository = DefaultPromoRepository( + promotionsSupplier = promotionsSupplier, + tangemApi = tangemApi, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val campaign = PromoCampaignId.WhaleSwapCashback + private val userWalletId = UserWalletId("abcdef012345") + 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, + all = All( + timeline = Timeline("2026-06-23T00:00:00.000Z", "2026-08-31T20:59:59.000Z"), + tokens = listOf( + PromoToken( + tokenId = "tether", + tokenAddress = "0xToken", + tokenSymbol = "USDT", + tokenName = "Tether USD", + networkId = "ethereum", + decimals = 6, + ), + ), + status = "active", + link = "", + ), + ) + + @BeforeEach + fun setUp() = clearMocks(promotionsSupplier, tangemApi) + + @Test + fun `GIVEN active campaign present and not enrolled WHEN getCampaignState THEN Available`() = runTest { + // Arrange + 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 { 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 + 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 with backend token`() = runTest { + // Arrange + val data = PromotionRegistrationResponse.RegistrationData( + campaignId = campaign.slug, + registeredAt = "2026-07-06T09:27:13.363Z", + tokenReward = PromotionRegistrationResponse.RegisteredTokenRewardDto( + tokenAddress = "0xCanonical", + networkId = "ethereum", + tokenId = "tether", + ), + ) + 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 = EnrolledTokenReward("0xCanonical", "ethereum", "tether") + assertThat(result).isEqualTo(EnrollResult.Success(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","tokenId":"usd-coin"}}} + """.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 = EnrolledTokenReward("0xOther", "base", "usd-coin") + assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(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(resultTokenReward)) + } + + @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() + } +} \ 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..ab330fd49b --- /dev/null +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt @@ -0,0 +1,70 @@ +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( + tokenId = "tether", + tokenAddress = "0xdac1", + tokenSymbol = "USDT", + tokenName = "Tether USD", + networkId = "ethereum", + decimals = 6, + ), + ), + status = "active", + link = "", + ) + + // Act + val result = PromoCampaignConverter.toAvailable(campaign, all) + + // Assert + assertThat(result.campaign).isEqualTo(campaign) + assertThat(result.payoutTokens).containsExactly( + 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")) + } + + @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/visa/build.gradle.kts b/data/visa/build.gradle.kts index 5a5ccd9dec..420ab0b3d2 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) @@ -47,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/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/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 3b7fe7face..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 @@ -118,6 +118,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( ) }, error = null, + virtualAccount = null, ) is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( source = StatusSource.CACHE, 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/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 30297c0d46..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 @@ -293,5 +293,20 @@ internal interface TangemPayDataModule { appCoroutineScope = appCoroutineScope, ) } + + @Provides + fun provideCreateVirtualAccountOrderUseCase( + onboardingRepository: OnboardingRepository, + pollingUseCase: StartTangemPayOrderPollingUseCase, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + appCoroutineScope: AppCoroutineScope, + ): CreateVirtualAccountOrderUseCase { + return CreateVirtualAccountOrderUseCase( + onboardingRepository = onboardingRepository, + pollingUseCase = pollingUseCase, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + appCoroutineScope = appCoroutineScope, + ) + } } } \ No newline at end of file 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/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 57447e1f15..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 @@ -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) @@ -114,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, @@ -341,7 +342,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) @@ -376,6 +377,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 +392,84 @@ 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. + * + * 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 + + val accountInstance = productInstances.firstOrNull { + 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") + VirtualAccountOnramp.BankCredentialsError + }, + ifRight = { credentials -> + VirtualAccountOnramp.Available( + productInstanceId = accountInstance.id, + bankCredentials = credentials, + ) + }, + ) + } + + 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") + null + }, + ifRight = { channels -> + if (channels.contains(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT)) { + VirtualAccountOnramp.Eligible + } else { + null + } + }, ) } 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..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 @@ -6,11 +6,13 @@ 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 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 @@ -18,6 +20,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 @@ -36,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, @@ -105,6 +108,18 @@ 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) + }.flatMap { response -> + val result = response.result ?: return@flatMap VisaApiError.UnknownWithoutCode.left() + BankCredentialsConverter.convert(result).right() + } + } + override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean { return tangemPayStorage.isTangemPayDeactivated(userWalletId) } @@ -156,6 +171,42 @@ 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 = idempotencyKey, + ), + ) + }.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) + } + } + + 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") @@ -170,7 +221,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) } @@ -226,6 +277,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/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/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..2f028c7ba4 --- /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.Result): 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..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 @@ -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 @@ -56,12 +57,13 @@ 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/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt index 23ee03289a..7034052655 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt @@ -6,6 +6,7 @@ import com.tangem.datasource.api.pay.models.response.TangemPayErrorResponse import com.tangem.datasource.di.NetworkMoshi import com.tangem.domain.visa.error.VisaApiError import com.tangem.utils.converter.Converter +import com.tangem.utils.logging.TangemLogger import javax.inject.Inject import javax.inject.Singleton @@ -23,7 +24,7 @@ internal class TangemPayErrorConverter @Inject constructor( if (value.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) return VisaApiError.RefreshTokenExpired val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode - return runCatching { + runCatching { tangemPayErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode }.map { VisaApiError.fromBackendError(it) @@ -31,6 +32,7 @@ internal class TangemPayErrorConverter @Inject constructor( VisaApiError.UnknownWithoutCode } } else { + TangemLogger.e("Not HttpException. ${value.message}", value) VisaApiError.UnknownWithoutCode } } 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..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 @@ -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,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 @@ -40,6 +48,12 @@ internal interface VirtualAccountDataModule { @Singleton fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher + @Binds + @Singleton + fun bindVirtualAccountActivationRepository( + impl: DefaultVirtualAccountActivationRepository, + ): VirtualAccountActivationRepository + companion object { @Provides @@ -77,5 +91,34 @@ internal interface VirtualAccountDataModule { keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" }, ) {} } + + @Provides + @Singleton + fun provideActivateVirtualAccountUseCase( + repository: VirtualAccountActivationRepository, + ): 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/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..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 @@ -1,19 +1,40 @@ 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.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 +42,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 +53,43 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( source = StatusSource.ONLY_CACHE, ) } + + private suspend fun getBalance(userWalletId: UserWalletId): Either { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + 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/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/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt index 38ccbe554a..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 @@ -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 @@ -23,6 +24,7 @@ internal class MockAwareOnboardingRepository @Inject constructor( ) : OnboardingRepository { private val mockOrderIds: MutableSet = ConcurrentHashMap.newKeySet() + private val mockVaOrderIds: MutableSet = ConcurrentHashMap.newKeySet() private val isMockMode: Boolean get() = apiConfigsManager @@ -47,6 +49,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) @@ -68,6 +75,39 @@ internal class MockAwareOnboardingRepository @Inject constructor( return real.getOrderId(userWalletId) } + 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, idempotencyKey) + } + + 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 clearVirtualAccountOrderId(userWalletId: UserWalletId) { + if (isMockMode) { + mockVaOrderIds.remove(userWalletId) + return + } + real.clearVirtualAccountOrderId(userWalletId) + } + override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either = real.hasTangemPayInWallet(userWalletId) @@ -77,6 +117,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) @@ -102,5 +146,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 new file mode 100644 index 0000000000..178766c64b --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt @@ -0,0 +1,509 @@ +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 +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.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 +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, + ) + + @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 }}" } + } + + /** 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 + @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.clearVirtualAccountOrderId(userWalletId) } just Runs + 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, + ), + ) + coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } + } + + @Test + fun `GIVEN toggle on and ACCOUNT instance but bank credentials fetch fails WHEN invoke THEN virtualAccount is Error`() = + runTest { + // Arrange + val customerInfo = buildCustomerInfo( + productInstances = listOf(cardProductInstance, accountProductInstance), + ) + stubHappyPath(customerInfo) + every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true + coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs + 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).isEqualTo(VirtualAccountOnramp.BankCredentialsError) + coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } + } + + @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.getVirtualAccountOrderId(userWalletId) } returns null + 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.getVirtualAccountOrderId(userWalletId) } returns null + coEvery { + onboardingRepository.fetchCustomerEligibility(userWalletId) + } returns VisaApiError.UnknownWithoutCode.left() + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + 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/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..c514bd2cfe --- /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.Result( + 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.Result( + 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/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..e578dcb5be --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt @@ -0,0 +1,93 @@ +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.utils.coroutines.TestingCoroutineDispatcherProvider +import io.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 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() { + clearMocks(networkFactory, tangemPayCurrencyFactory, singleNetworkStatusFetcher, userWalletsListRepository) + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + every { tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) } returns token + coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right() + } + + @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 network cannot be created WHEN invoke THEN on-chain fetch skipped`() = runTest { + // Arrange + every { + networkFactory.create(any(), any(), any()) + } returns null + + // Act + fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + + // Assert + coVerify(exactly = 0) { 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/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..091069392f --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt @@ -0,0 +1,250 @@ +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( + tokenId = "usd-coin", + tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + tokenSymbol = "USDC", + tokenName = "USD Coin", + networkId = "ethereum", + decimals = 6, + ), + ), + 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/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/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/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/MarketingCampaignAmount.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt new file mode 100644 index 0000000000..5edd396f2e --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.marketing.models + +import java.math.BigDecimal + +/** + * 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 { + if (minAmount == null && maxAmount == null) return true + + 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/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/MarketingCampaignAmountTest.kt b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt new file mode 100644 index 0000000000..2ab57b78a4 --- /dev/null +++ b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt @@ -0,0 +1,72 @@ +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 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 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)).isFalse() + } + + @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/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..87d54579f0 --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt @@ -0,0 +1,76 @@ +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.matchesUsdAmount +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) } + // 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() + } + } + + 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 + } + } +} \ 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..15c9f0cf35 --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt @@ -0,0 +1,20 @@ +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 + + suspend fun dismissBanner(campaignId: Int) +} \ No newline at end of file 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/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/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/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..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,6 +149,9 @@ 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), or `null` + * when not applicable (feature toggle off / wallet not eligible). + * Transient: not persisted in the local cache. */ @Serializable data class Loaded( @@ -160,6 +163,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..c01ac5cb73 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt @@ -0,0 +1,41 @@ +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 { + + /** 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 + + /** + * A VA on-ramp order has been submitted and is being provisioned (order status NEW/PROCESSING, or + * COMPLETED before the ACCOUNT product instance appears). The bank-transfer entry point stays visible; + * tapping it shows the "Preparing your banking details" bottom sheet. Transient — never persisted, + * re-resolved on the next status fetch, cleared once the ACCOUNT instance appears or the order is canceled. + */ + @Serializable + data object Processing : VirtualAccountOnramp + + /** + * VA product instance exists, but its bank credentials failed to load. The bank-transfer entry point + * stays visible; tapping it surfaces a retryable "couldn't load banking details" error instead of the + * requisites. Transient — never persisted, re-resolved on the next status fetch. + */ + @Serializable + data object BankCredentialsError : VirtualAccountOnramp +} \ No newline at end of file diff --git a/domain/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..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 @@ -4,14 +4,45 @@ enum class TangemPayEligibilityType { BANNER, DETAILS, + DEEPLINK, + + BANNER_VIRTUAL_ACCOUNT, + DETAILS_VIRTUAL_ACCOUNT, + DEEPLINK_VIRTUAL_ACCOUNT, + + VISA_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 + "VISA_VIRTUAL_ACCOUNT" -> VISA_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/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/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 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..bb94ae415d --- /dev/null +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.promo.models + +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 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..e741682340 --- /dev/null +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt @@ -0,0 +1,16 @@ +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 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..c55f3a92b2 --- /dev/null +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt @@ -0,0 +1,37 @@ +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( + val start: Instant, + val end: Instant, +) + +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: EnrolledTokenReward + + 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/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..414d3bfa06 --- /dev/null +++ b/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt @@ -0,0 +1,18 @@ +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 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..89e8244638 --- /dev/null +++ b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt @@ -0,0 +1,59 @@ +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.EnrolledTokenReward +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", "0xUser", "tether") + private val resultTokenReward = EnrolledTokenReward("0xToken", "ethereum", "tether") + + @BeforeEach + fun setUp() = clearMocks(repository) + + @Test + fun `GIVEN repo returns Success WHEN invoke THEN Right Success`() = runTest { + // Arrange + val expected = EnrollResult.Success(resultTokenReward) + 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/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/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/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 { 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/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 + get() = productInstances.filter { it.specificationDataType == ProductInstance.SpecificationDataType.CARD } + enum class State { NEW, ACTIVE, @@ -67,6 +71,7 @@ data class CustomerInfo( val actualCardLimit: TangemPayCardLimit?, val adminCardLimit: TangemPayCardLimit?, val status: Status, + val specificationDataType: SpecificationDataType, ) { enum class Status { NEW, @@ -82,6 +87,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..61fe300e64 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,11 +2,13 @@ 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 import com.tangem.domain.visa.error.VisaApiError +@Suppress("TooManyFunctions") interface OnboardingRepository { suspend fun validateDeeplink(link: String): Either @@ -17,17 +19,44 @@ 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) 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, + idempotencyKey: String, + ): Either + + suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? + + suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String) + + suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId) + suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either 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 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..207e037cd1 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt @@ -0,0 +1,52 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.utils.coroutines.AppCoroutineScope +import kotlinx.coroutines.launch +import java.util.UUID + +/** + * 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, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + private val appCoroutineScope: AppCoroutineScope, +) { + suspend operator fun invoke( + userWalletId: UserWalletId, + paymentAccountAddress: String, + ): Either = either { + onboardingRepository.getVirtualAccountOrderId(userWalletId) + ?: run { + val vaOrderId = onboardingRepository.createVirtualAccountOrder( + userWalletId = userWalletId, + paymentAccountAddress = paymentAccountAddress, + 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), + userWalletId = userWalletId, + ) + } + } + } +} \ No newline at end of file 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/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/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..306f0f6fcf --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt @@ -0,0 +1,75 @@ +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.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.test.core.TestAppCoroutineScope +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +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 paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk(relaxUnitFun = true) + + private val useCase = CreateVirtualAccountOrderUseCase( + onboardingRepository = onboardingRepository, + pollingUseCase = pollingUseCase, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + appCoroutineScope = TestAppCoroutineScope(), + ) + + private val userWalletId = UserWalletId("1234567890ABCDEF") + private val paymentAccountAddress = "0xcollateral" + + @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(), any()) } + coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) } + coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) } + coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(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, any()) + } 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) } + coVerify(exactly = 1) { paymentAccountStatusFetcher.markVirtualAccountProcessing(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, any()) + } 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()) } + coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(any()) } + } +} \ 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/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/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/converter/ChooseTokenListItemConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt index 84238b8387..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 @@ -101,8 +103,9 @@ internal class ChooseTokenListItemConverter( private fun AccountStatus.CryptoPortfolio.toPortfolioItem( params: TokenConverterParams.Account, ): TokensListItemUM.Portfolio { - val tokenList: TokenList = this.tokenList - val account: Account.CryptoPortfolio = this.account + val displayedStatus = filterForDisplay() + val account: Account.CryptoPortfolio = displayedStatus.account + val displayedTokenList: TokenList = displayedStatus.tokenList val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId) val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount -> onAccountItemClick(clickedAccount, isExpanded) @@ -116,10 +119,9 @@ internal class ChooseTokenListItemConverter( fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) }, subtitle2StateProvider = { _ -> null }, ) - val accountItem = converter.convert(tokenList.totalFiatBalance) - val tokenConverter = tokenStatusConverter(this) - val tokensListState = convertTokenList(tokenConverter, tokenList, this) - val items = tokensListState.tokensList + val accountItem = converter.convert(displayedTokenList.totalFiatBalance) + val items = displayedTokenList.toUmData(tokenStatusConverter(this)).tokensList + return TokensListPortfolioItemConverter( tokenItemUM = accountItem, isExpanded = isExpanded, @@ -128,29 +130,47 @@ internal class ChooseTokenListItemConverter( ).convert(Unit) } + private fun AccountStatus.CryptoPortfolio.filterForDisplay(): AccountStatus.CryptoPortfolio { + val filteredTokenList = filterTokenList(tokenList, this) + return copy( + account = account.copy(cryptoCurrencies = filteredTokenList.flattenCurrencies().map { it.currency }), + tokenList = filteredTokenList, + ) + } + + private fun TokenList.recalculateBalance(): TokenList { + val statuses = flattenCurrencies().toNonEmptyListOrNull() ?: return this + val total = TotalFiatBalanceCalculator.calculate(statuses) + return when (this) { + TokenList.Empty -> this + is TokenList.Ungrouped -> copy(totalFiatBalance = total) + is TokenList.GroupedByNetwork -> copy(totalFiatBalance = total) + } + } + private fun convertTokenList( tokenConverter: TokenItemStateConverter, tokenListParam: TokenList, account: AccountStatus.CryptoPortfolio, - ): TokenListUMData { - return when (val tokenList = filterTokenList(tokenListParam, account)) { - is TokenList.Empty -> TokenListUMData.EmptyList - is TokenList.GroupedByNetwork -> TokenListUMData.TokenList( - tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(), - totalTokensCount = tokenList.flattenCurrencies().size, - ) - is TokenList.Ungrouped -> TokenListUMData.TokenList( - tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(), - totalTokensCount = tokenList.flattenCurrencies().size, - ) - } + ): TokenListUMData = filterTokenList(tokenListParam, account).toUmData(tokenConverter) + + private fun TokenList.toUmData(tokenConverter: TokenItemStateConverter): TokenListUMData = when (this) { + TokenList.Empty -> TokenListUMData.EmptyList + is TokenList.GroupedByNetwork -> TokenListUMData.TokenList( + tokensList = toGroupedItems(tokenConverter).toPersistentList(), + totalTokensCount = flattenCurrencies().size, + ) + is TokenList.Ungrouped -> TokenListUMData.TokenList( + tokensList = toUngroupedItems(tokenConverter).toPersistentList(), + totalTokensCount = flattenCurrencies().size, + ) } private fun List.filterCurrencies(account: AccountStatus): List = filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) } private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList { - return when (tokenList) { + val filtered = when (tokenList) { TokenList.Empty -> TokenList.Empty is TokenList.Ungrouped -> { val filtered = tokenList.currencies.filterCurrencies(account) @@ -166,6 +186,8 @@ internal class ChooseTokenListItemConverter( if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups) } } + + return filtered.recalculateBalance() } private fun CryptoCurrencyStatus.filterByQuery(): Boolean { diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt index 4b0097ba4b..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 @@ -1,25 +1,28 @@ 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 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.ChooseTokenAnalyticsPayload -import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +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 -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.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -30,6 +33,9 @@ import javax.inject.Inject internal class ChooseTokenModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, marketBlockDelegateFactory: MarketBlockDelegate.Factory, + predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory, + addToPortfolioManagerFactory: AddToPortfolioManager.Factory, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, paramsContainer: ParamsContainer, ) : Model() { @@ -38,34 +44,65 @@ 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, + ) + } + + /** 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( + predefinedTokens = block.predefinedTokens, + searchQueryState = searchQueryState, + addToPortfolioManager = addToPortfolioManager, + addToPortfolioSlot = bottomSheetNavigation, + modelScope = modelScope, + tokenFilter = bridge.tokenFilter, + portfolioTokenKeys = portfolioTokenKeysFlow, + ) + } + + 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,12 +111,12 @@ internal class ChooseTokenModel @Inject constructor( initialValue = ChooseTokenFullUM( initialUM = initialState.value, portfolioBlock = bridge.fullPortfolioBlock.value, - marketsBlock = null, + chooserBlock = null, ), ) init { - if (bridge.settings.isShowMarketBlock) { + if (bridge.settings.chooserBlock is ChooserBlock.Market) { modelScope.launch { delay(MARKETS_INITIAL_LOAD_DELAY) marketBlockDelegate.loadDefaultMarkets() @@ -87,16 +124,24 @@ 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) } .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() } @@ -112,7 +157,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..647c3b3965 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt @@ -0,0 +1,119 @@ +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>, + @Assisted private val portfolioTokenKeys: Flow>>, +) { + + init { + predefinedTokens + .onEach { tokens -> tokenFilter.value = buildTokenFilter(tokens) } + .launchIn(modelScope) + } + + val stateFlow: Flow = combine( + predefinedTokens, + searchQueryState, + portfolioTokenKeys, + ) { tokens, query, portfolioKeys -> + val filtered = tokens.filter { token -> + token.hasValidNetwork() && + token.matchesQuery(query.value) && + !portfolioKeys.contains(token.toKey()) + } + 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) + } + } + + /** 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 + + 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>, + portfolioTokenKeys: Flow>>, + ): PredefinedTokensBlockDelegate + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/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..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 @@ -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,88 @@ 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 = 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 +968,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 +1007,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() + } + + @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 { + // 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 }), + portfolioTokenKeys: MutableStateFlow>> = MutableStateFlow(emptySet()), + ): PredefinedTokensBlockDelegate = PredefinedTokensBlockDelegate( + predefinedTokens = predefinedTokens, + searchQueryState = searchQueryState, + addToPortfolioManager = addToPortfolioManager, + addToPortfolioSlot = addToPortfolioSlot, + modelScope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)), + tokenFilter = tokenFilter, + portfolioTokenKeys = portfolioTokenKeys, + ) + + 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/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 3b6e587c78..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) @@ -50,6 +51,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..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 @@ -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 @@ -34,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 @@ -66,7 +70,9 @@ 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() { private val params: DetailsComponent.Params = paramsContainer.require() @@ -99,6 +105,7 @@ internal class DetailsModel @Inject constructor( ) addTangemPayItemIfEligible() + addVirtualAccountItemIfEligible() state = MutableStateFlow( value = DetailsUM( @@ -285,5 +292,33 @@ internal class DetailsModel @Inject constructor( } } + private fun addVirtualAccountItemIfEligible() { + modelScope.launch { + val isVirtualAccountEnabled = virtualAccountFeatureToggles.isVirtualAccountsEnabled + val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS) + if (eligibility is VirtualAccountEligibility.Available && isVirtualAccountEnabled) { + 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 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..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 @@ -16,23 +16,21 @@ 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 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 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 +64,8 @@ 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() + protected val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk() // Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven. protected val wcSlot = slot() @@ -91,6 +91,8 @@ 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 { virtualAccountFeatureToggles.isVirtualAccountsEnabled } returns true every { itemsBuilder.buildAll( @@ -132,6 +134,8 @@ internal abstract class DetailsModelTestBase { generateBuyTangemCardLinkUseCase = generateBuyTangemCardLinkUseCase, analyticsEventHandler = analyticsEventHandler, tangemPayEligibilityManager = tangemPayEligibilityManager, + getVirtualAccountEligibilityUseCase = getVirtualAccountEligibilityUseCase, + virtualAccountFeatureToggles = virtualAccountFeatureToggles, ) protected fun stubBuildAllReturns(list: ImmutableList) { 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/marketing/api/build.gradle.kts b/features/marketing/api/build.gradle.kts new file mode 100644 index 0000000000..b0d679e453 --- /dev/null +++ b/features/marketing/api/build.gradle.kts @@ -0,0 +1,22 @@ +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) + + // 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 new file mode 100644 index 0000000000..19dd18fc97 --- /dev/null +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt @@ -0,0 +1,46 @@ +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 { + + /** + * 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 single banner rendered inline next to a host item (currently an onramp provider offer). */ + data class Linked(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..0c517aa39d --- /dev/null +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt @@ -0,0 +1,19 @@ +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 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?, +) \ 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..8480aa8a28 --- /dev/null +++ b/features/marketing/impl/build.gradle.kts @@ -0,0 +1,51 @@ +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.material3) + 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..89acb2c793 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt @@ -0,0 +1,63 @@ +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.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 + +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, + ) + } + + @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( + 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..8808bb4097 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt @@ -0,0 +1,143 @@ +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) } + } + is MarketingBannerComponent.Params.Linked -> + p.requestFlow.map { request -> + request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd) } + } + } + + 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() + + modelScope.launch { + combine( + flow = campaigns, + flow2 = amountUsd, + flow3 = dismissedIds, + ) { list, usd, dismissed -> + list.asSequence() + .filterNot { it.id in dismissed } + .filter { it.matchesUsdAmount(usd) } + .filter { matchesUiType(it) } + .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 matchesUiType(campaign: MarketingCampaign): Boolean = when (params) { + is MarketingBannerComponent.Params.Standalone -> + campaign.banner.uiType == MarketingBanner.UiType.STANDALONE + is MarketingBannerComponent.Params.Linked -> + campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER + } + + private data class MarketingRequest( + val screen: MarketingScreen, + val amountUsd: BigDecimal?, + ) + + private fun MarketingCampaign.toUM() = MarketingBannerUM( + 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 -> MarketingBannerUM.IconAlign.LEFT + null -> if (banner.isDismissible) MarketingBannerUM.IconAlign.LEFT else MarketingBannerUM.IconAlign.RIGHT + }, + isDismissible = banner.isDismissible, + deeplink = banner.deeplink, + providerIds = providerIds?.toSet().orEmpty(), + ) + + 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/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/MarketingBanner.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt new file mode 100644 index 0000000000..719a63a24f --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt @@ -0,0 +1,143 @@ +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.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, + variant = TangemMessageBanner.Variant.Default, + showGlowRing = false, + onClick = if (hasDeeplink) onClick else null, + 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..200871fc3e --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt @@ -0,0 +1,16 @@ +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?, + 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 new file mode 100644 index 0000000000..51967a0363 --- /dev/null +++ b/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt @@ -0,0 +1,335 @@ +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.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 +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 +import org.junit.jupiter.params.ParameterizedTest + +@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 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")), + campaign(2, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("moonpay")), + campaign(3, MarketingBanner.UiType.STANDALONE), + ).right() + val model = createModel( + 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, 2) + assertThat(content.banners.first { it.campaignId == 1 }.providerIds).containsExactly("mercuryo") + assertThat(content.banners.first { it.campaignId == 2 }.providerIds).containsExactly("moonpay") + } + } + + @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") } + } + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN iconAlign and dismissible WHEN mapped THEN align follows design default`( + model: IconAlignModel, + ) = runTest { + // Arrange + coEvery { getMarketingBanner(onrampScreen, null) } returns listOf( + standaloneCampaign(id = 1, iconAlign = model.iconAlign, isDismissible = model.isDismissible), + ).right() + val bannerModel = createModel( + MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))), + ) + + // Act + advanceUntilIdle() + + // Assert + val content = bannerModel.uiState.value as MarketingBannerListUM.Content + assertThat(content.banners.single().iconAlign).isEqualTo(model.expected) + } + + private fun standaloneCampaign(id: Int, iconAlign: MarketingBanner.IconAlign?, isDismissible: Boolean) = + campaign(id, MarketingBanner.UiType.STANDALONE).let { base -> + base.copy(banner = base.banner.copy(iconAlign = iconAlign, isDismissible = isDismissible)) + } + + internal data class IconAlignModel( + val iconAlign: MarketingBanner.IconAlign?, + val isDismissible: Boolean, + val expected: MarketingBannerUM.IconAlign, + ) + + private fun provideTestModels() = listOf( + // Backend omits iconAlign -> derived from dismissible (design default) + IconAlignModel(iconAlign = null, isDismissible = false, expected = MarketingBannerUM.IconAlign.RIGHT), + IconAlignModel(iconAlign = null, isDismissible = true, expected = MarketingBannerUM.IconAlign.LEFT), + // Explicit backend value is always honored regardless of dismissible + IconAlignModel( + iconAlign = MarketingBanner.IconAlign.LEFT, + isDismissible = false, + expected = MarketingBannerUM.IconAlign.LEFT, + ), + IconAlignModel( + iconAlign = MarketingBanner.IconAlign.RIGHT, + isDismissible = true, + expected = MarketingBannerUM.IconAlign.RIGHT, + ), + ) +} \ No newline at end of file diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 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/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/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..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) } } @@ -205,6 +242,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 +257,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%"), @@ -248,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 @@ -270,6 +316,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 +332,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%"), @@ -315,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/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..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 @@ -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() } @@ -85,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/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..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 @@ -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) { +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) + 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/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/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/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/.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 diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index a510cfa91b..983c6465b4 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,47 @@ 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) + 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 */ - implementation(projects.core.decompose) - implementation(projects.core.navigation) - implementation(projects.core.ui) - implementation(projects.core.analytics) + 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.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.compose.material3) 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) @@ -42,6 +62,6 @@ dependencies { kapt(deps.hilt.kapt) /** Tests */ - testImplementation(deps.test.junit5) - testImplementation(deps.test.truth) + 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/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..ba62b7560d --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt @@ -0,0 +1,92 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.foundation.layout.WindowInsets +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.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +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.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 +import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignFooter + +internal class ActivateCampaignBottomSheetComponent( + appComponentContext: AppComponentContext, + chooseTokenComponentFactory: ChooseTokenComponent.Factory, + private val params: Params, + val onDismiss: () -> Unit, +) : ComposableModularContentComponent, 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), + ) + + @Composable + override fun Title() { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { TangemButton.Close(onClick = onDismiss) }, + ) + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + ActivateCampaignContent(um = state, modifier = modifier) + + if (state.isChoosingToken) { + ChooseTokenBottomSheet(state.onChooseTokenDismiss) + } + } + + @Composable + 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 = onChooseTokenDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = onChooseTokenDismiss, + content = { chooseTokenComponent.Content(modifier = Modifier.fillMaxWidth()) }, + ) + } + + data class Params( + val campaignType: CampaignType, + val userWalletId: UserWalletId, + val modelCallbacks: ActivateCampaignModelCallbacks, + ) + + interface ActivateCampaignModelCallbacks { + val onActivated: (CampaignType) -> 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 new file mode 100644 index 0000000000..398bc5d891 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt @@ -0,0 +1,61 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.components.PrimaryButton +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.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.CampaignTypeToContentConverter +import com.tangem.features.promobanners.impl.campaigns.ui.AlreadyActivatedCampaignContent + +internal class CampaignAlreadyActivatedBottomSheetComponent( + appComponentContext: AppComponentContext, + params: Params, + val onDismiss: () -> Unit, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { + + private val campaignName = CampaignTypeToContentConverter().convert(params.campaignType).name + + @Composable + override fun Title() { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { TangemButton.Close(onClick = onDismiss) }, + ) + } + + @Composable + override fun Content(modifier: Modifier) { + AlreadyActivatedCampaignContent( + message = resourceReference( + R.string.promo_campaign_already_activated_title, + wrappedList(campaignName), + ), + modifier = modifier, + ) + } + + @Composable + override fun Footer() { + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_close), + onClick = onDismiss, + ) + } + + 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/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..03c1c5b5e3 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt @@ -0,0 +1,59 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.PrimaryButton +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.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.CampaignTypeToContentConverter +import com.tangem.features.promobanners.impl.campaigns.ui.CampaignEnrolledMessageContent + +internal class CampaignEnrolledBottomSheetComponent( + params: Params, + private val onDismissRequest: () -> Unit, +) : ComposableModularContentComponent { + + private val campaignName = CampaignTypeToContentConverter().convert(params.campaignType).name + + @Composable + override fun Title() { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { TangemButton.Close(onClick = onDismissRequest) }, + ) + } + + @Composable + override fun Content(modifier: Modifier) { + CampaignEnrolledMessageContent( + message = resourceReference( + R.string.promo_campaign_enroll_success_title, + wrappedList(campaignName), + ), + modifier = modifier, + ) + } + + @Composable + override fun Footer() { + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_close), + 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/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..17a0ba50bd --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt @@ -0,0 +1,149 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Box +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 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.TangemBottomSheetType +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.rememberLastNonNull +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 +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.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 = null, + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val bottomSheet by bottomSheetSlot.subscribeAsState() + val activeChild = bottomSheet.child?.instance + val displayedChild = rememberLastNonNull(activeChild) + + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = activeChild != null, + onDismissRequest = model::onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors3.bg.secondary, + type = TangemBottomSheetType.Modal, + onBack = model::onDismiss, + title = { + displayedChild?.Title() + }, + content = { + 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.padding(12.dp)) { + displayedChild?.Footer() + } + }, + ) + } + + private fun bottomSheetChild( + config: CampaignsBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableModularContentComponent { + val context = childByContext(componentContext) + return when (config) { + CampaignsBottomSheetConfig.NotActive -> NotActiveCampaignBottomSheetComponent( + onDismissRequest = model::onDismiss, + ) + is CampaignsBottomSheetConfig.Enrolled -> CampaignEnrolledBottomSheetComponent( + params = CampaignEnrolledBottomSheetComponent.Params( + campaignType = config.campaignType, + ), + onDismissRequest = model::onDismiss, + ) + is CampaignsBottomSheetConfig.Activate -> ActivateCampaignBottomSheetComponent( + appComponentContext = context, + chooseTokenComponentFactory = chooseTokenComponentFactory, + onDismiss = model::onDismiss, + 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 + }, + ), + ) + is CampaignsBottomSheetConfig.AlreadyActivated -> CampaignAlreadyActivatedBottomSheetComponent( + appComponentContext = context, + params = CampaignAlreadyActivatedBottomSheetComponent.Params( + campaignType = config.campaignType, + ), + onDismiss = model::onDismiss, + ) + } + } + + @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..8e90b9da88 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt @@ -0,0 +1,42 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.PrimaryButton +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.stringResourceSafe +import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.ui.NotActiveCampaignMessageContent + +internal class NotActiveCampaignBottomSheetComponent( + private val onDismissRequest: () -> Unit, +) : ComposableModularContentComponent { + + @Composable + override fun Title() { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { TangemButton.Close(onClick = onDismissRequest) }, + ) + } + + @Composable + override fun Content(modifier: Modifier) { + NotActiveCampaignMessageContent(modifier = modifier) + } + + @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/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..6c5c480f96 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt @@ -0,0 +1,18 @@ +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 + +internal class CampaignIdConverter @Inject constructor() : + Converter { + + override fun convert(value: String): CampaignType? { + return when (value) { + PromoCampaignId.ReactivationCashback.slug -> CampaignType.ReactivationCashback(campaignId = value) + PromoCampaignId.WhaleSwapCashback.slug -> CampaignType.WhaleSwapCashback(campaignId = value) + else -> null + } + } +} \ 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..1a6fb4a0bc --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/deeplink/DefaultCampaignsDeepLinkHandler.kt @@ -0,0 +1,49 @@ +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 +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, + getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + promoBannersFeatureToggles: PromoBannersFeatureToggles, +) : CampaignsDeepLinkHandler { + + init { + if (promoBannersFeatureToggles.isCampaignsToggleEnabled) { + // 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") + } + } + + @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 new file mode 100644 index 0000000000..a3ad2a34b4 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt @@ -0,0 +1,53 @@ +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 +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 + + @Binds + @Singleton + fun bindCampaignsDeepLinkHandlerFactory( + factory: DefaultCampaignsDeepLinkHandler.Factory, + ): CampaignsDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindCampaignsService(service: DefaultCampaignsService): CampaignsService +} + +@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..458103a638 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt @@ -0,0 +1,53 @@ +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 + +/** + * 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. + * + * [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 logo: TangemIconUM, + val title: TextReference, + val description: TextReference, + val selectedToken: TokenItemState?, + val selectedAccount: SelectedAccountUM?, + val isChoosingToken: Boolean, + val footerUM: FooterUM, + val onChooseTokenDismiss: () -> Unit, + val onLearnMoreClick: () -> Unit, + val onChooseTokenClick: () -> 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/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..bd8106428e --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt @@ -0,0 +1,15 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class CampaignType { + + abstract val campaignId: String + + @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/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..607977d1a3 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt @@ -0,0 +1,39 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +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 new file mode 100644 index 0000000000..cbfec595c7 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt @@ -0,0 +1,27 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +import com.tangem.domain.models.wallet.UserWalletId +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, + val userWalletId: UserWalletId, + ) : CampaignsBottomSheetConfig() + + @Serializable + data class AlreadyActivated( + 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..2720ea1bef --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt @@ -0,0 +1,269 @@ +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 +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.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.supplier.MultiAccountListSupplier +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.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 +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 +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.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.first +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 + +@Suppress("LongParameterList") +@ModelScoped +internal class ActivateCampaignsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + chooseTokenBridgeFactory: ChooseTokenBridge.Factory, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val multiAccountListSupplier: MultiAccountListSupplier, + private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase, + private val urlOpener: UrlOpener, + @GlobalUiMessageSender private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, + private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase, + private val predefinedTokenResolver: PredefinedTokenResolver, + private val getWalletsUseCase: GetWalletsUseCase, +) : 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 val campaignId: PromoCampaignId = params.campaignType.toPromoCampaignId() + private val campaignContent = CampaignTypeToContentConverter().convert(campaignType) + + private val predefinedTokensFlow = MutableStateFlow>(emptyList()) + + private var enrollJob: Job? = null + + val uiState: StateFlow + field = MutableStateFlow(buildInitialModel()) + + val bridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + modelScope = modelScope, + settings = ChooseTokenBridge.Settings( + title = resourceReference(R.string.common_choose_token), + chooserBlock = ChooserBlock.Predefined(predefinedTokensFlow), + isShowPaymentAccount = false, + isShowSingleCurrencyWallets = true, + ), + ) + + init { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType)) + + getSelectedAppCurrencyUseCase.invokeOrDefault() + .onEach { appCurrency = it } + .launchIn(modelScope) + + bridge.onCurrencyChosen.receiveAsFlow() + .onEach { result -> onTokenChosen(result) } + .launchIn(modelScope) + + 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( + 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) } + } + + private fun onChooseTokenDismiss() { + uiState.update { it.copy(isChoosingToken = false) } + } + + private fun onEnrollClick(selectedToken: CryptoCurrency.Token, networkAddress: NetworkAddress) { + if (enrollJob?.isActive == true) return + + analyticsEventHandler.send( + PromoCampaignsAnalyticsEvent.EnrollButtonClicked( + campaignType = campaignType, + token = selectedToken.symbol, + blockchain = selectedToken.network.name, + ), + ) + + 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 = getAllUserWalletIds(), + ).onLeft { error -> + TangemLogger.e("Error enrolling campaign ${campaignType.campaignId}", error) + messageSender.send(ToastMessage(message = resourceReference(R.string.common_unknown_error))) + }.onRight { + handleEnrollResponse(it) + } + } + } + + private fun getAllUserWalletIds() = getWalletsUseCase + .invokeSync() + .map { it.walletId } + + 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(campaignContent.termsUrl) + } + + private fun onLearnMoreClick() { + 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 (hasMultipleCryptoPortfolioAccounts()) { + when (val account = result.account.account) { + is Account.CryptoPortfolio -> SelectedAccountUM( + iconState = accountIconConverter.convert(account), + name = account.accountName.toUM().value, + ) + // Payment accounts are hidden in the chooser and don't count towards accounts mode, + // so there is no account label to show for them. + is Account.Payment, + is Account.Virtual, + -> null + } + } else { + null + } + + val tokenItem = TokenItemStateConverter( + appCurrency = appCurrency, + 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( + isChoosingToken = false, + selectedToken = tokenItem, + selectedAccount = selectedAccountUM, + footerUM = FooterUM( + label = resourceReference(R.string.promo_campaign_enroll), + onPrimaryButtonClick = { + onEnrollClick( + selectedToken = selectedToken, + networkAddress = networkAddress, + ) + }, + terms = TermsUM( + text = resourceReference(R.string.promo_campaign_terms_agreement_android), + linkText = resourceReference( + R.string.promo_campaign_terms_link, + wrappedList(campaignContent.name), + ), + onTermsClick = ::onTermsClick, + ), + ), + ) + } + } + } +} \ 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 new file mode 100644 index 0000000000..30a67f20d7 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt @@ -0,0 +1,97 @@ +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.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.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.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 +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.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +internal class CampaignsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val campaignIdConverter: CampaignIdConverter, + campaignsService: CampaignsService, + private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase, + @GlobalUiMessageSender private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + init { + campaignsService.campaignFlow + .onEach { request -> + resolveStartNavigation( + campaignType = campaignIdConverter.convert(request.campaignId), + userWalletId = request.userWalletId, + ) + } + .launchIn(modelScope) + } + + 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) } + } + } + + 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.Available -> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId) + is PromoCampaignState.NotActive -> CampaignsBottomSheetConfig.NotActive + } + }, + ) + + fun onDismiss() { + bottomSheetNavigation.dismiss() + } + + fun onActivated(campaignType: CampaignType) { + bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType)) + } + + fun onAlreadyActivated(campaignType: CampaignType) { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened()) + 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/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/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..eaa186f620 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/CampaignsService.kt @@ -0,0 +1,24 @@ +package com.tangem.features.promobanners.impl.campaigns.service + +import com.tangem.domain.models.wallet.UserWalletId +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 campaign requested via [show]. */ + val campaignFlow: Flow + + /** 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 new file mode 100644 index 0000000000..fda538fd32 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/DefaultCampaignsService.kt @@ -0,0 +1,19 @@ +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 +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, 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 new file mode 100644 index 0000000000..ffe504bfb8 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignContent.kt @@ -0,0 +1,143 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import android.content.res.Configuration +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.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.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 +import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM +import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM + +@Composable +internal fun ActivateCampaignContent(um: ActivateCampaignUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TangemIcon( + tangemIconUM = um.logo, + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + ) + + SpacerH32() + + Text( + text = um.title.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth(), + ) + + SpacerH8() + + Text( + text = um.description.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + modifier = Modifier.fillMaxWidth(), + ) + + SpacerH12() + + Text( + text = stringResourceSafe(R.string.common_learn_more), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.primary, + modifier = Modifier + .align(Alignment.Start) + .clickable(onClick = um.onLearnMoreClick), + ) + + SelectedTokenContent( + selectedToken = um.selectedToken, + selectedAccount = um.selectedAccount, + onChooseTokenClick = um.onChooseTokenClick, + ) + } +} + +@Composable +private fun SelectedTokenContent( + selectedToken: TokenItemState?, + selectedAccount: SelectedAccountUM?, + onChooseTokenClick: () -> Unit, +) { + if (selectedToken != null) { + SpacerH24() + + Text( + text = stringResourceSafe(R.string.promo_campaign_select_cashback_account), + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + modifier = Modifier.fillMaxWidth(), + ) + + SpacerH12() + + PromoCampaignTokenItem( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(TangemTheme.colors3.bg.tertiary) + .clickable { + onChooseTokenClick.invoke() + }, + 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..1a0a7f6111 --- /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.typography3.caption.medium, + color = TangemTheme.colors3.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..5423185db8 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/AlreadyActivatedCampaignContent.kt @@ -0,0 +1,71 @@ +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.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_info_24 + +@Composable +internal fun AlreadyActivatedCampaignContent(message: TextReference, modifier: Modifier = Modifier) { + 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 = message.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(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_AlreadyActivatedCampaignContent() { + TangemThemePreviewRedesign { + Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) { + AlreadyActivatedCampaignContent(message = stringReference("You’re already enrolled 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/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..d5ecf0e4d6 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignEnrolledMessageContent.kt @@ -0,0 +1,77 @@ +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 +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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 + +@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(), + ) + } +} + +// 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 new file mode 100644 index 0000000000..6589fd0136 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignPreviewData.kt @@ -0,0 +1,68 @@ +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.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( + 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.", + ), + selectedToken = tokenItem, + selectedAccount = selectedAccount, + isChoosingToken = false, + footerUM = footer, + onChooseTokenDismiss = {}, + onLearnMoreClick = {}, + 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 new file mode 100644 index 0000000000..6b7019e690 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/NotActiveCampaignMessageContent.kt @@ -0,0 +1,69 @@ +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.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 + +@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 = stringResourceSafe(R.string.promo_campaign_not_active_title), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + SpacerH8() + + Text( + 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(), + ) + } +} \ 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 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/model/PromoBannersBlockModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt index 9e251f128c..1f665f15d6 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt @@ -2,10 +2,15 @@ package com.tangem.features.promobanners.impl.model import androidx.core.net.toUri import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.deeplink.DeeplinkLauncher +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.features.promobanners.api.PromoBannersBlockComponent import com.tangem.features.promobanners.impl.analytics.PromoBannerAnalyticsEvent @@ -26,6 +31,7 @@ import javax.inject.Inject private typealias ShownBannerKey = Pair +@Suppress("LongParameterList") @ModelScoped internal class PromoBannersBlockModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, @@ -34,6 +40,7 @@ internal class PromoBannersBlockModel @Inject constructor( private val deeplinkLauncher: DeeplinkLauncher, private val analyticsEventHandler: AnalyticsEventHandler, private val userWalletsListRepository: UserWalletsListRepository, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() @@ -206,7 +213,12 @@ internal class PromoBannersBlockModel @Inject constructor( private fun onButtonClick(displayId: Int, deeplink: String?) { analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName)) - deeplink?.let { deeplinkLauncher.launch(appendSurveyDisplayId(it, displayId)) } + + if (deeplink.isNullOrBlank()) { + uiMessageSender.send(ToastMessage(message = resourceReference(R.string.common_something_went_wrong))) + } else { + deeplinkLauncher.launch(appendSurveyDisplayId(deeplink, displayId)) + } } private fun appendSurveyDisplayId(deeplink: String, displayId: Int): String { diff --git a/features/promo-banners/impl/src/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 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..9c571d85d0 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt @@ -0,0 +1,44 @@ +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 = "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), + ) + + 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..f6925ce535 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt @@ -0,0 +1,325 @@ +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.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 +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 +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 multiAccountListSupplier: MultiAccountListSupplier = 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 getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk() + private val getWalletsUseCase: GetWalletsUseCase = mockk() + private val predefinedTokenResolver: PredefinedTokenResolver = 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, + multiAccountListSupplier, + enrollPromoCampaignUseCase, + getWalletsUseCase, + 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(EnrolledTokenReward(tokenAddress = "a", networkId = "b", tokenId = "d"))) + 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, + 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(EnrolledTokenReward(tokenAddress = "a", networkId = "b", tokenId = "d"))) + 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, + // 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 + } + 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) + 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 } + } + return ActivateCampaignsModel( + paramsContainer = MutableParamsContainer( + ActivateCampaignBottomSheetComponent.Params( + campaignType = campaignType, + userWalletId = userWalletId, + modelCallbacks = modelCallbacks, + ), + ), + dispatchers = createTestingCoroutineDispatcherProvider(), + chooseTokenBridgeFactory = chooseTokenBridgeFactory, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + multiAccountListSupplier = multiAccountListSupplier, + enrollPromoCampaignUseCase = enrollPromoCampaignUseCase, + urlOpener = urlOpener, + messageSender = messageSender, + analyticsEventHandler = analyticsEventHandler, + getPromoCampaignStateUseCase = getPromoCampaignStateUseCase, + predefinedTokenResolver = predefinedTokenResolver, + getWalletsUseCase = getWalletsUseCase, + ) + } + + 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") + + // 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 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..1cf7126b76 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModelTest.kt @@ -0,0 +1,178 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import arrow.core.Either +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) + + 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 = "whale-swap-cashback", 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 `WHEN onAlreadyActivated THEN analytics sent`() = runTest { + // Arrange + val model = createModel(campaignFlow = emptyFlow()) + advanceUntilIdle() + + // Act + model.onAlreadyActivated(CampaignType.WhaleSwapCashback(campaignId = "1")) + + // Assert + verify(exactly = 1) { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened()) + } + model.onDestroy() + } + + @Test + fun `WHEN onActivated THEN no analytics sent`() = runTest { + // Arrange + val model = createModel(campaignFlow = emptyFlow()) + advanceUntilIdle() + + // Act + model.onActivated(CampaignType.WhaleSwapCashback(campaignId = "1")) + + // Assert + verify { analyticsEventHandler wasNot Called } + 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 = "whale-swap-cashback", expectedPromoId = PromoCampaignId.WhaleSwapCashback), + KnownCampaignModel(campaignId = "reactivation-cashback", 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 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 diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index 3613b3df3d..1c1e0791d4 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -68,6 +68,8 @@ dependencies { implementation(projects.domain.notifications.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.marketing.models) + implementation(projects.domain.onramp.models) /** Common */ implementation(projects.common.ui) @@ -80,6 +82,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..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 @@ -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( @@ -163,6 +170,9 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M amountState = uiState.amountState, clickIntents = uiState.clickIntents, modifier = Modifier.background(TangemTheme.colors.background.secondary), + extraContent = { + marketingBannerComponent.Content(Modifier.fillMaxWidth()) + }, ) StakingStep.Confirmation -> StakingConfirmationContent( amountState = uiState.amountState, diff --git a/features/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/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index f955ca80e0..cc8d3a94f4 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -50,27 +50,30 @@ 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) 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) + 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..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 @@ -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 @@ -173,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() @@ -224,6 +230,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 +383,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/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/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index ab9078108e..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 @@ -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), @@ -80,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/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/TangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt index 51137888da..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 @@ -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), @@ -109,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 c32727f8ce..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 @@ -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) @@ -58,6 +63,7 @@ internal class TangemPayCardPageScreenComponent( } } + @Suppress("LongMethod") private fun bottomSheetChild( navigation: TangemPayCardNavigation, componentContext: ComponentContext, @@ -97,6 +103,41 @@ 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, + userWalletId = navigation.userWalletId, + paymentAccountAddress = navigation.paymentAccountAddress, + onDismiss = model.bottomSheetNavigation::dismiss, + onShowDetails = model::onShowVirtualAccountRequisites, + onShowBankingDetailsError = model::showVaBankingDetailsError, + onOrderCreated = model::onVirtualAccountOrderCreated, + ), + ) + 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() }, + onDetailsShown = model::onVaBankingDetailsShown, + onShareClicked = model::onVaShareDetailsClicked, + 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( 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..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 @@ -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) @@ -94,6 +99,7 @@ internal class TangemPayDetailsComponent( } } + @Suppress("LongMethod") private fun bottomSheetChild( navigation: TangemPayDetailsNavigation, componentContext: ComponentContext, @@ -126,6 +132,41 @@ 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, + userWalletId = navigation.userWalletId, + paymentAccountAddress = navigation.paymentAccountAddress, + onDismiss = model.bottomSheetNavigation::dismiss, + onShowDetails = model::onShowVirtualAccountRequisites, + onShowBankingDetailsError = model::showVaBankingDetailsError, + onOrderCreated = model::onVirtualAccountOrderCreated, + ), + ) + 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() }, + onDetailsShown = model::onVaBankingDetailsShown, + onShareClicked = model::onVaShareDetailsClicked, + 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( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVaBankingDetailsErrorComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVaBankingDetailsErrorComponent.kt new file mode 100644 index 0000000000..08901ed2c9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVaBankingDetailsErrorComponent.kt @@ -0,0 +1,44 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.tangempay.model.TangemPayVaBankingDetailsErrorModel +import com.tangem.features.tangempay.ui.TangemPayVaBankingDetailsErrorBottomSheet + +/** + * Error bottom sheet shown when VA bank credentials fail to load ([VirtualAccountOnramp.BankCredentialsError]). + * + * "Try again" re-fetches the payment account status while showing a loader on the button; on success the + * resolved on-ramp is handed back via [Params.onResolved] (the parent opens the bank-transfer sheet), otherwise + * the error stays visible with the loader cleared. + */ +internal class TangemPayVaBankingDetailsErrorComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayVaBankingDetailsErrorModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + TangemPayVaBankingDetailsErrorBottomSheet(state = state) + } + + data class Params( + val userWalletId: UserWalletId, + val onDismiss: () -> Unit, + val onContactSupport: () -> Unit, + val onResolved: (VirtualAccountOnramp) -> Unit, + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositComponent.kt new file mode 100644 index 0000000000..64a293d93b --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositComponent.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.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() { + 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 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/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/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index 0cc4f2aa1d..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 @@ -45,6 +45,16 @@ internal interface TangemPayModelModule { @ClassKey(TangemPayAddFundsModel::class) fun bindTangemPayAddFundsModel(model: TangemPayAddFundsModel): Model + @Binds + @IntoMap + @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 425d6813f8..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 @@ -1,6 +1,8 @@ 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 import com.tangem.domain.models.wallet.UserWalletId @@ -30,6 +32,25 @@ internal sealed class TangemPayCardNavigation { val fiatBalance: SerializedBigDecimal, val depositAddress: String, val cryptoCurrency: CryptoCurrency, + val virtualAccountOnramp: VirtualAccountOnramp?, + ) : TangemPayCardNavigation() + + @Serializable + data class VirtualAccountDeposit( + val virtualAccountOnramp: VirtualAccountOnramp, + val userWalletId: UserWalletId, + val paymentAccountAddress: String, + ) : TangemPayCardNavigation() + + @Serializable + data class VirtualAccountRequisites( + val userWalletId: UserWalletId, + val bankCredentials: BankCredentials, + ) : TangemPayCardNavigation() + + @Serializable + data class VaBankingDetailsError( + val userWalletId: UserWalletId, ) : 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..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 @@ -1,6 +1,8 @@ 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 import com.tangem.domain.models.serialization.SerializedCurrency @@ -21,6 +23,25 @@ internal sealed class TangemPayDetailsNavigation { val fiatBalance: SerializedBigDecimal, val depositAddress: String, val cryptoCurrency: CryptoCurrency, + val virtualAccountOnramp: VirtualAccountOnramp?, + ) : TangemPayDetailsNavigation() + + @Serializable + data class VirtualAccountDeposit( + val virtualAccountOnramp: VirtualAccountOnramp, + val userWalletId: UserWalletId, + val paymentAccountAddress: String, + ) : TangemPayDetailsNavigation() + + @Serializable + data class VirtualAccountRequisites( + val userWalletId: UserWalletId, + val bankCredentials: BankCredentials, + ) : TangemPayDetailsNavigation() + + @Serializable + data class VaBankingDetailsError( + val userWalletId: UserWalletId, ) : TangemPayDetailsNavigation() @Serializable 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/entity/TangemPayVirtualAccountDepositUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt new file mode 100644 index 0000000000..069d11a08f --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt @@ -0,0 +1,28 @@ +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 isLoading: 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..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,16 +1,19 @@ 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 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,12 +23,22 @@ internal class TangemPayAddFundsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val tangemPayFeatureToggles: TangemPayFeatureToggles, + 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, @@ -43,6 +56,7 @@ internal class TangemPayAddFundsModel @Inject constructor( return TangemPayAddFundsUMConverter( listener = params.listener, isRedesignEnabled = tangemPayFeatureToggles.isRedesignEnabled, + 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 f8ee0c6d02..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 @@ -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,10 +25,14 @@ 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 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 @@ -66,16 +71,17 @@ import kotlinx.coroutines.launch import javax.inject.Inject import com.tangem.core.ui.R as CoreUiR -@Suppress("LongParameterList", "LargeClass") +@Suppress("LongParameterList", "LargeClass", "TooManyFunctions") @Stable @ModelScoped internal class TangemPayCardPageModel @Inject constructor( paramsContainer: ParamsContainer, - paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val analytics: AnalyticsEventHandler, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val uiMessageSender: UiMessageSender, private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase, @@ -449,6 +455,7 @@ internal class TangemPayCardPageModel @Inject constructor( cryptoBalance = balance.cryptoBalance.balance, depositAddress = balance.cryptoBalance.depositAddress, cryptoCurrency = cryptoCurrency, + virtualAccountOnramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount }, ), ) } @@ -484,6 +491,97 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + override fun onClickBankTransfer() { + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + when (val onramp = loaded.virtualAccount) { + null -> return + VirtualAccountOnramp.Processing -> showVaPreparing() + // BankCredentialsError opens the deposit intro first; the retryable error sheet is shown from + // its "Show details" action (see onShowDetailsClick). + is VirtualAccountOnramp.Available, + VirtualAccountOnramp.Eligible, + is VirtualAccountOnramp.BankCredentialsError, + -> openVirtualAccountDeposit(onramp, loaded) + } + } + + private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) { + analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked()) + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayCardNavigation.VirtualAccountDeposit( + virtualAccountOnramp = onramp, + userWalletId = userWalletId, + paymentAccountAddress = loaded.balance.cryptoBalance.depositAddress, + ), + ) + } + + fun showVaBankingDetailsError() { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayCardNavigation.VaBankingDetailsError(userWalletId = userWalletId), + ) + } + + private fun showVaPreparing() { + bottomSheetNavigation.dismiss() + uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage()) + } + + fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { + when (onramp) { + // Bank credentials just loaded on retry — show the requisites straight away ([REDACTED_TASK_KEY]), + // instead of the intro deposit sheet that would need another "Show details" tap. + is VirtualAccountOnramp.Available -> onShowVirtualAccountRequisites(onramp) + else -> { + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + openVirtualAccountDeposit(onramp, loaded) + } + } + } + + fun onContactSupportClicked() { + analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay)) + val customerId = currentStatus.value.ifLoadedOrNull { it.customerId } ?: return + modelScope.launch { + sendFeedbackEmailUseCase.invoke( + type = FeedbackEmailType.Visa.FeatureIsBeta( + walletMetaInfo = WalletMetaInfo(userWalletId = userWalletId), + customerId = customerId, + ), + ) + } + } + + fun onVirtualAccountOrderCreated() { + analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation()) + bottomSheetNavigation.dismiss() + router.push(TangemPayCardDetailsInnerRoute.VirtualAccountDepositSuccess) + } + + fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayCardNavigation.VirtualAccountRequisites( + userWalletId = userWalletId, + bankCredentials = onramp.bankCredentials, + ), + ) + } + + 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 076230a318..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 @@ -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 @@ -61,12 +62,12 @@ 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( paramsContainer: ParamsContainer, - paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, override val dispatchers: CoroutineDispatcherProvider, private val analytics: AnalyticsEventHandler, private val router: Router, @@ -175,6 +176,7 @@ internal class TangemPayDetailsModel @Inject constructor( cryptoBalance = balance.availableForWithdrawal, depositAddress = balance.cryptoBalance.depositAddress, cryptoCurrency = cryptoCurrency, + virtualAccountOnramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount }, ), ) } @@ -309,6 +311,84 @@ internal class TangemPayDetailsModel @Inject constructor( ) } + override fun onClickBankTransfer() { + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + when (val onramp = loaded.virtualAccount) { + null -> return + VirtualAccountOnramp.Processing -> showVaPreparing() + // BankCredentialsError opens the deposit intro first; the retryable error sheet is shown from + // its "Show details" action (see onShowDetailsClick). + is VirtualAccountOnramp.Available, + VirtualAccountOnramp.Eligible, + is VirtualAccountOnramp.BankCredentialsError, + -> openVirtualAccountDeposit(onramp, loaded) + } + } + + private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) { + analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked()) + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.VirtualAccountDeposit( + virtualAccountOnramp = onramp, + userWalletId = userWalletId, + paymentAccountAddress = loaded.balance.cryptoBalance.depositAddress, + ), + ) + } + + fun showVaBankingDetailsError() { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.VaBankingDetailsError(userWalletId = userWalletId), + ) + } + + private fun showVaPreparing() { + bottomSheetNavigation.dismiss() + uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage()) + } + + fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { + when (onramp) { + // Bank credentials just loaded on retry — show the requisites straight away ([REDACTED_TASK_KEY]), + // instead of the intro deposit sheet that would need another "Show details" tap. + is VirtualAccountOnramp.Available -> onShowVirtualAccountRequisites(onramp) + else -> { + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + openVirtualAccountDeposit(onramp, loaded) + } + } + } + + fun onVirtualAccountOrderCreated() { + analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation()) + bottomSheetNavigation.dismiss() + router.push(TangemPayAccountDetailsInnerRoute.VirtualAccountDepositSuccess) + } + + fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.VirtualAccountRequisites( + userWalletId = userWalletId, + bankCredentials = onramp.bankCredentials, + ), + ) + } + + 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/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 new file mode 100644 index 0000000000..e85616df0b --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt @@ -0,0 +1,114 @@ +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.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +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 +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 +@ModelScoped +internal class TangemPayVirtualAccountDepositModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val urlOpener: UrlOpener, + private val uiMessageSender: UiMessageSender, + private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase, + private val analytics: AnalyticsEventHandler, +) : Model() { + + private val params = paramsContainer.require() + + 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) }, + ), + ) + + 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 -> { + analytics.send(TangemPayAnalyticsEvents.VaShowDetailsClicked()) + params.onShowDetails(params.virtualAccountOnramp) + } + VirtualAccountOnramp.Eligible -> { + analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked()) + createVirtualAccountOrder() + } + VirtualAccountOnramp.BankCredentialsError -> params.onShowBankingDetailsError() + // Processing never reaches this sheet (the Preparing message is shown instead); defensive. + VirtualAccountOnramp.Processing -> onDismiss() + } + } + + 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() + }, + ) + } + } + + 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..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 @@ -1,82 +1,88 @@ 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.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 = resourceReference(R.string.tangempay_topup_bank_transfer_title), + description = resourceReference(R.string.tangempay_topup_bank_transfer_body), + 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/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/TangemPayVaBankingDetailsErrorBottomSheet.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVaBankingDetailsErrorBottomSheet.kt new file mode 100644 index 0000000000..0d92ed8afa --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVaBankingDetailsErrorBottomSheet.kt @@ -0,0 +1,171 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_error_28 +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayVaBankingDetailsErrorUM + +@Composable +internal fun TangemPayVaBankingDetailsErrorBottomSheet(state: TangemPayVaBankingDetailsErrorUM) { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = state.onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + type = TangemBottomSheetType.Modal, + containerColor = TangemTheme.colors3.bg.secondary, + title = { + TangemTopBar( + type = TangemTopBarType.BottomSheet, + title = null, + endContent = { TangemButton.Close(onClick = state.onDismiss) }, + ) + }, + content = { _ -> Content(state) }, + ) +} + +@Composable +private fun Content(state: TangemPayVaBankingDetailsErrorUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x4), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + WarningIcon(modifier = Modifier.padding(top = TangemTheme.dimens2.x4)) + TitleText( + text = resourceReference(R.string.tangempay_va_banking_details_error_title), + modifier = Modifier.padding(top = TangemTheme.dimens2.x8), + ) + SubtitleText( + text = resourceReference(R.string.tangempay_va_banking_details_error_description), + modifier = Modifier.padding(top = TangemTheme.dimens2.x2), + ) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens2.x8), + text = resourceReference(R.string.common_contact_support), + variant = TangemButton.Variant.Secondary, + size = TangemButton.Size.X12, + isEnabled = !state.isRetryLoading, + onClick = state.onContactSupportClick, + ) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens2.x2), + text = resourceReference(R.string.common_retry), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + isLoading = state.isRetryLoading, + isEnabled = !state.isRetryLoading, + onClick = state.onRetryClick, + ) + } +} + +@Composable +private fun WarningIcon(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(TangemTheme.dimens2.x20) + .clip(CircleShape) + .background(TangemTheme.colors3.bg.status.warningSubtle), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x7), + imageVector = Icons.ic_error_28, + contentDescription = null, + tint = TangemTheme.colors3.icon.status.warning, + ) + } +} + +@Composable +private fun TitleText(text: TextReference, modifier: Modifier = Modifier) { + Text( + modifier = modifier.fillMaxWidth(), + text = text.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun SubtitleText(text: TextReference, modifier: Modifier = Modifier) { + Text( + modifier = modifier.fillMaxWidth(), + text = text.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = TextAlign.Center, + ) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayVaBankingDetailsErrorPreview( + @PreviewParameter(VaBankingDetailsErrorPreviewProvider::class) state: TangemPayVaBankingDetailsErrorUM, +) { + TangemThemePreviewRedesign { + Content( + state = state, + modifier = Modifier.background(TangemTheme.colors3.bg.secondary), + ) + } +} + +private class VaBankingDetailsErrorPreviewProvider : + CollectionPreviewParameterProvider( + collection = listOf( + TangemPayVaBankingDetailsErrorUM( + isRetryLoading = false, + onRetryClick = {}, + onContactSupportClick = {}, + onDismiss = {}, + ), + TangemPayVaBankingDetailsErrorUM( + isRetryLoading = true, + onRetryClick = {}, + onContactSupportClick = {}, + onDismiss = {}, + ), + ), + ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVirtualAccountDepositBottomSheet.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVirtualAccountDepositBottomSheet.kt new file mode 100644 index 0000000000..5f28f8b685 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVirtualAccountDepositBottomSheet.kt @@ -0,0 +1,333 @@ +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.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 +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.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 +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() + .verticalScroll(rememberScrollState()) + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x4), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IntroIcons(modifier = Modifier.padding(top = TangemTheme.dimens2.x4)) + TitleText( + text = resourceReference(R.string.tangempay_bank_transfer_intro_title), + modifier = Modifier.padding(top = TangemTheme.dimens2.x8), + ) + SubtitleText( + text = resourceReference(R.string.tangempay_bank_transfer_intro_subtitle), + modifier = Modifier.padding(top = TangemTheme.dimens2.x2), + ) + FeesBlock( + fees = state.fees, + modifier = Modifier.padding(top = TangemTheme.dimens2.x6), + ) + InfoNotification( + 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 = 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) { + 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 = stringResourceSafe(R.string.tangempay_bank_transfer_fee_header), + 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 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 { + 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(), + 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.x6), + painter = painterResource(CoreUiR.drawable.ic_polygon_22), + contentDescription = null, + tint = TangemTheme.colors3.icon.staticDark, + ) + } + } +} + +@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 = 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 = {}, + 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 diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt index 41c6409547..11d88250e4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt @@ -5,7 +5,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush @@ -16,6 +15,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerHMax +import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference @@ -25,6 +25,7 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_success_24 import com.tangem.features.tangempay.details.impl.R +import dev.chrisbanes.haze.HazeStyle private const val DEFAULT_FADE_COLOR = 0xFF9FC824 private val BlurRadius = 192.dp @@ -46,7 +47,7 @@ internal fun TangemPaySuccessScreenWrapper( Box( modifier = Modifier .matchParentSize() - .blur(BlurRadius) + .hazeForegroundEffectTangem(style = HazeStyle(blurRadius = BlurRadius, tint = null)) .drawBehind { val w = size.width drawRect( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt index 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 new file mode 100644 index 0000000000..2426355a94 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/VirtualAccountRequisites.kt @@ -0,0 +1,50 @@ +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 + +/** + * 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 = resourceReference(R.string.virtual_account_requisites_beneficiary_name), + titleForShare = "Beneficiary name", + value = beneficiaryName, + ), + RequisitesRow( + title = resourceReference(R.string.virtual_account_requisites_beneficiary_address), + titleForShare = "Beneficiary address", + value = beneficiaryAddress, + ), + RequisitesRow( + 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 = resourceReference(R.string.virtual_account_requisites_routing_number), + titleForShare = "Routing number", + value = routingNumber, + ), +) \ 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 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..e0c11a246f --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModelTest.kt @@ -0,0 +1,187 @@ +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.analytics.api.AnalyticsEventHandler +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.tangempay.TangemPayAnalyticsEvents +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 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, + onShowBankingDetailsError, + onOrderCreated, + uiMessageSender, + analytics, + ) + } + + @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()) } + verify(exactly = 1) { analytics.send(ofType()) } + 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 + 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() + verify(exactly = 1) { analytics.send(ofType()) } + verify(exactly = 1) { analytics.send(ofType()) } + } + + @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, + onShowBankingDetailsError = onShowBankingDetailsError, + onOrderCreated = onOrderCreated, + ), + ), + dispatchers = createTestingCoroutineDispatcherProvider(), + urlOpener = urlOpener, + uiMessageSender = uiMessageSender, + createVirtualAccountOrderUseCase = createVirtualAccountOrderUseCase, + analytics = analytics, + ) + + 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/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..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 @@ -9,7 +9,9 @@ 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.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 @@ -351,6 +353,61 @@ 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"), + } +} + +@Suppress("BooleanPropertyNaming") +internal data class TangemMessageBannerStory( + val variant: TangemMessageBanner.Variant, + val contentAlign: TangemMessageBanner.ContentAlign, + val hasGlowRing: Boolean, + val hasDescription: Boolean, + val hasSecondaryButton: Boolean, + val hasPrimaryButton: Boolean, + val hasCloseButton: Boolean, + val hasSlotStart: Boolean, + val hasSlotEnd: Boolean, + val hasExtraContent: Boolean, + val isClickable: Boolean, + val background: Background, + val onVariantChange: (TangemMessageBanner.Variant) -> Unit, + val onContentAlignChange: (TangemMessageBanner.ContentAlign) -> Unit, + val onGlowRingToggle: () -> Unit, + val onDescriptionToggle: () -> Unit, + val onSecondaryButtonToggle: () -> Unit, + val onPrimaryButtonToggle: () -> Unit, + val onCloseButtonToggle: () -> Unit, + val onSlotStartToggle: () -> Unit, + val onSlotEndToggle: () -> Unit, + val onExtraContentToggle: () -> Unit, + val onClickableToggle: () -> Unit, + val onBackgroundChange: (Background) -> Unit, +) : DsStoryBookPage { + + /** Backdrop the banner preview is rendered on top of. */ + enum class Background(val label: String) { + BgPrimary("bg.primary"), + BgSecondary("bg.secondary"), + BgInverse("bg.inverse"), + } +} + internal data class TangemBadgeV2Story( val variant: TangemBadge.Variant, val status: TangemBadge.Status, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index 1083082fea..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 @@ -20,7 +20,9 @@ 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.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 @@ -39,6 +41,8 @@ private fun buildDsStories() = listOf( DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory), 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/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/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..d2f78444b7 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/Build.kt @@ -0,0 +1,39 @@ +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, + isClickable = false, + 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) } }, + onClickableToggle = { updateStory { it.copy(isClickable = !it.isClickable) } }, + 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..aa3d7dfdb3 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/TangemMessageBannerStory.kt @@ -0,0 +1,335 @@ +@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, + onClick = if (state.isClickable) { + {} + } else { + null + }, + 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) + ToggleRow( + label = "clickable (no buttons only)", + checked = state.isClickable, + onToggle = state.onClickableToggle, + ) + } + } +} + +@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 fb4ee3a9a7..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 @@ -42,7 +42,9 @@ 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.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 @@ -96,11 +98,13 @@ 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) is TangemFadeStory -> TangemFadeStory(state = storyState) is TangemTopNavigationStory -> TangemTopNavigationStory(state = storyState) + is TangemMessageBannerStory -> TangemMessageBannerStory(state = storyState) } } } \ 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 + }, ) } } 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/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/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..05c12b9572 --- /dev/null +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountAddFundsBottomSheetComponent.kt @@ -0,0 +1,39 @@ +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 + +/** + * 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, + // 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( + val title: TextReference, + 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/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..9473fb0f4d --- /dev/null +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt @@ -0,0 +1,16 @@ +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 +} \ 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..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") @@ -11,11 +12,32 @@ android { } 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) - implementation(projects.core.configToggles) + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.decompose.ext.compose) - implementation(deps.compose.runtime) + /** Other */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) /** DI */ implementation(deps.hilt.android) 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 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..85990403bd --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt @@ -0,0 +1,112 @@ +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.TangemShimmer +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 -> TangemShimmer( + modifier = Modifier.size(width = 160.dp, height = 56.dp), + style = TangemTheme.typography3.heading.medium, + ) + 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..d402fa6c06 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt @@ -0,0 +1,67 @@ +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.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.VirtualAccountAddFundsBottomSheetComponent +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 private val params: VirtualAccountMainComponent.Params, + private val addFundsComponentFactory: VirtualAccountAddFundsBottomSheetComponent.Factory, +) : 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 -> addFundsComponentFactory.create( + context = childByContext(componentContext), + params = VirtualAccountAddFundsBottomSheetComponent.Params( + userWalletId = params.userWalletId, + listener = model, + requisites = config.requisites, + dailyDepositLimit = config.dailyDepositLimit, + ), + ) + } + } + + @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..c6bf5c1c0b --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt @@ -0,0 +1,108 @@ +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 +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.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(), 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), + balance = VirtualAccountBalanceBlockState.Content( + fiatBalance = stringReference("$0.00"), + isBalanceFlickering = false, + ), + isBalanceHidden = false, + onBackClick = { router.pop() }, + onMenuClick = {}, + onAddFundsClick = ::onAddFundsClick, + onSendClick = {}, + ) + + private fun buildRequisites(details: VirtualAccountDepositDetails) = listOf( + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = resourceReference(R.string.virtual_account_requisites_beneficiary_name), + titleForShare = "Beneficiary name", + value = details.beneficiaryName, + ), + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = resourceReference(R.string.virtual_account_requisites_account_number), + titleForShare = "Account number", + value = details.accountNumber, + ), + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = resourceReference(R.string.virtual_account_requisites_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..9f5a2e601b --- /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.details.component.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/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/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/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..73d7146db7 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheet.kt @@ -0,0 +1,331 @@ +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.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 +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() + .verticalScroll(rememberScrollState()) + .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/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..8461bca104 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsModel.kt @@ -0,0 +1,90 @@ +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.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent +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 = if (params.shouldSkipIntro) buildDetailsContent() else buildIntroContent(), + ), + ) + + init { + if (params.shouldSkipIntro) { + // send analytics + params.onDetailsShown() + } + } + + fun onDismiss() { + params.listener.onAddFundsDismiss() + } + + private fun buildIntroContent() = VirtualAccountAddFundsUM.Content.Intro( + onShowDetailsClick = ::showDetailsContent, + ) + + private fun showDetailsContent() { + params.onDetailsShown() + uiState.update { state -> state.copy(content = buildDetailsContent()) } + } + + 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( + 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 { + 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/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..2733530929 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.virtualaccount.main.di + +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 +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface VirtualAccountMainComponentModule { + + @Binds + fun bindVirtualAccountMainComponentFactory( + factory: DefaultVirtualAccountMainComponent.Factory, + ): VirtualAccountMainComponent.Factory + + @Binds + fun bindVirtualAccountAddFundsComponentFactory( + factory: DefaultVirtualAccountAddFundsBottomSheetComponent.Factory, + ): VirtualAccountAddFundsBottomSheetComponent.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..4b85e1f7d4 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt @@ -0,0 +1,26 @@ +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 +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 + + @Binds + @IntoMap + @ClassKey(VirtualAccountAddFundsModel::class) + fun bindVirtualAccountAddFundsModel(model: VirtualAccountAddFundsModel): Model +} \ No newline at end of file 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 0000000000..40030c0fd9 Binary files /dev/null and b/features/virtual-accounts/onboarding/impl/src/main/res/drawable/bg_virtual_account_onboarding.webp differ 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() diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts index 37aee32489..3012e0c1ca 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,8 @@ dependencies { implementation(projects.domain.feedback) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.balanceHiding) + implementation(projects.domain.marketing.models) + implementation(projects.domain.onramp.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 = {}, ) } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index d39e802608..cc4bd95f86 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 ^ 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, 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..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 @@ -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\$")) || @@ -30,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\$")) } diff --git a/settings.gradle.kts b/settings.gradle.kts index b61afb33b1..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") @@ -414,6 +417,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") @@ -432,6 +437,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") @@ -457,6 +464,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") @@ -476,6 +484,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")