Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-28 13:49:54 +04:00
commit 17197af0f7
361 changed files with 16712 additions and 296 deletions

View file

@ -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.<component>`, folder
`core/ui/.../ds2/<component>/`. The component name is `Tangem<Name>`.
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<Name>` (like `TangemButton.Variant` / `TangemButton.Size`), not boolean flags.
@ -156,7 +160,8 @@ Page layout guidelines live in
- [ ] Component created under `core/ui/.../ds2/<component>/`, package `com.tangem.core.ui.ds2.<component>`.
- [ ] Named `Tangem<Name>`; 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<Name>` (not a set of boolean flags).
- [ ] All public types (enums, statuses, constants) declared inside the `object Tangem<Name>`.
- [ ] Convenient overloads provided (simpler `@Composable` overloads and/or `object` extension presets).

View file

@ -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)

View file

@ -313,6 +313,16 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="onboard-virtual-account"
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="news"
android:scheme="tangem" />
@ -337,6 +347,16 @@
android:host="yield"
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="campaigns"
android:scheme="tangem" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />

@ -1 +1 @@
Subproject commit f983c6defd0b2240eb6f134aefe30f7e93696795
Subproject commit 5559381cd92d7d8747ca4531462879915499d405

View file

@ -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),

View file

@ -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,

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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<Throwable, VirtualAccountActivationData> {
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<VirtualAccountActivationData> -> result.data.right()
}
}
}
override suspend fun getWithdrawalSignature(
hash: String,
preflightReadFilter: PreflightReadFilter,

View file

@ -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<Throwable, VirtualAccountActivationData> {
error("Not implemented")
}
override suspend fun getWithdrawalSignature(
hash: String,
preflightReadFilter: PreflightReadFilter,

View file

@ -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<VirtualAccountActivationData> {
override fun run(session: CardSession, callback: CompletionCallback<VirtualAccountActivationData>) {
coroutineScope.launch {
callback(runSuspend(session = session))
}
}
private suspend fun runSuspend(session: CardSession): CompletionResult<VirtualAccountActivationData> {
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<ExtendedPublicKey> -> 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<ExtendedPublicKey> {
val deferred = CompletableDeferred<CompletionResult<ExtendedPublicKey>>()
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
}
}

View file

@ -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<SaveWalletError, UserWallet> = 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,

View file

@ -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())

View file

@ -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) },

View file

@ -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,

View file

@ -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(
"""

View file

@ -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)

View file

@ -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,
),
)
}
}

View file

@ -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<OnboardVirtualAccountsDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val tangemPayMainDeepLink = mockk<TangemPayMainDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk()
}
@ -112,6 +118,10 @@ class DeepLinkFactoryTest {
every { create(any(), any()) } returns mockk()
}
private val campaignsDeepLinkHandlerFactory = mockk<CampaignsDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val marketsTokenExchangesDeepLinkFactory =
mockk<MarketsTokenExchangesDeepLinkHandler.Factory>(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)

View file

@ -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")

View file

@ -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) {

View file

@ -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"

View file

@ -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
}

View file

@ -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),
)
}

View file

@ -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"
}
]

View file

@ -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,
)

View file

@ -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<CampaignDto>,
)
@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<String>? = null,
@Json(name = "tokens") val tokens: List<CampaignTokenDto>? = 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,
)

View file

@ -23,6 +23,13 @@ interface TangemPayApi {
@GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
/** 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<BankCredentialsResponse>
@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<TangemPayEligibilityChannels>
/** 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<TangemPayEligibilityChannels>
@GET("v1/order/{order_id}")
suspend fun getOrder(
@Header("Authorization") authHeader: String,
@ -64,6 +77,13 @@ interface TangemPayApi {
@Body body: OrderRequest,
): ApiResponse<OrderResponse>
// TODO: Doston: [REDACTED_TASK_KEY] Unify with method above
@POST("v1/order")
suspend fun createVirtualAccountOrder(
@Header("Authorization") authHeader: String,
@Body body: VirtualAccountOrderRequest,
): ApiResponse<OrderResponse>
/** Customer offers — used to gate the issue-additional-card flow. */
@GET("v1/customer/offers")
suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse<CustomerOffersResponse>

View file

@ -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",
)
}

View file

@ -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?,
)
}

View file

@ -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")

View file

@ -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<String>,
@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,
)
}

View file

@ -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,
)
}

View file

@ -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,
)
}
}

View file

@ -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<StoryContentResponse>
// 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<YieldBoostStatusResponse>
@POST("/v2/promotion/registrations")
suspend fun createPromotionRegistration(
@Body body: CreatePromotionRegistrationBody,
): ApiResponse<PromotionRegistrationResponse>
// endregion
// region push notifications
@ -235,4 +243,18 @@ interface TangemTechApi {
@GET("v1/earn/networks")
suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse<EarnNetworkListResponse>
// 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<MarketingCampaignsResponse>
// endregion
}

View file

@ -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<Map<UserWalletId, PromotionsResponse>>(),
dispatchers = dispatchers,
)
}
}

View file

@ -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),

View file

@ -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}")

View file

@ -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<Map<UserWalletId, PromotionsResponse>>,
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
}
}

View file

@ -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
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -441,6 +441,7 @@
<string name="common_rename">Umbenennen</string>
<string name="common_required">Erforderlich</string>
<string name="common_reset">Zurücksetzen</string>
<string name="common_retry">Wiederholen</string>
<string name="common_save">Speichern</string>
<string name="common_save_changes">Änderungen speichern</string>
<string name="common_search">Suchen</string>
@ -907,9 +908,11 @@
<item quantity="one">Vermögenswert</item>
<item quantity="other">Vermögenswerte</item>
</plurals>
<string name="market_chart_bubble_no_amount">Kein Betrag auf Token</string>
<string name="market_chart_bubble_no_data">Keine Daten</string>
<string name="market_chart_bubble_total_value">Gesamtwert</string>
<string name="market_chart_can_not_load_data">Daten konnten nicht geladen werden</string>
<string name="market_chart_no_amount">Du hast keine Token mit diesem Betrag.</string>
<string name="market_chart_top_holding">Top-Halterung %s</string>
<string name="markets_about_coin_header">Über diesen Coin</string>
<string name="markets_add_to_my_portfolio_description">Um dieses Asset zu kaufen, zu tauschen oder zu erhalten, füge diesen Deinem Portfolio hinzu</string>
@ -1863,6 +1866,9 @@
<string name="tangempay_card_details_add_funds">Guthaben hinzufügen</string>
<string name="tangempay_card_details_add_funds_subtitle">Aufladeoptionen</string>
<string name="tangempay_card_details_add_to_wallet_button_text">Zu Google Wallet hinzufügen</string>
<string name="tangempay_card_details_awaiting_deposit_cancel_button">Stornieren %1$s, umziehen nach %2$s</string>
<string name="tangempay_card_details_awaiting_deposit_subtitle">Um die monatliche Gebühr für den Tarif zu bezahlen und die Karte zu nutzen</string>
<string name="tangempay_card_details_awaiting_deposit_title">Laden Sie Ihr Konto auf unter %1$s</string>
<string name="tangempay_card_details_card_number">Kartennummer</string>
<string name="tangempay_card_details_change_pin">PIN-Code</string>
<string name="tangempay_card_details_change_pin_success_description">Die Karte ist vollständig für Zahlungen bereit.</string>
@ -1897,6 +1903,8 @@
<string name="tangempay_card_details_rename_card_placeholder">Kartenname</string>
<string name="tangempay_card_details_reveal_text">Aufdecken</string>
<string name="tangempay_card_details_show_details">Kartendetails</string>
<string name="tangempay_card_details_system_downgrade_subtitle">Sollte der Kontostand unter null bleiben, werden Ihre „ %1$s “-Karten am %2$s</string>
<string name="tangempay_card_details_system_downgrade_title">Laden Sie Ihr Konto in Kürze auf.</string>
<string name="tangempay_card_details_title">Kartendetails</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Bitte versuche es später noch einmal.</string>
<string name="tangempay_card_details_unfreeze_card">Karte entsperren</string>
@ -1924,17 +1932,40 @@
<item quantity="one">%d Karte</item>
<item quantity="other">%d Karten</item>
</plurals>
<string name="tangempay_cashback_accruals_calc_description">Wir bearbeiten Käufe innerhalb von 5 Tagen nach der Transaktion und berücksichtigen nur abgeschlossene Transaktionen.</string>
<string name="tangempay_cashback_accruals_calc_title">Wie berechnen wir Cashback?</string>
<string name="tangempay_cashback_accruals_exceptions_description">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.</string>
<string name="tangempay_cashback_accruals_exceptions_title">Ausnahmen</string>
<string name="tangempay_cashback_accruals_pay_description">Vom 2. bis zum 5. des nächsten Monats</string>
<string name="tangempay_cashback_accruals_pay_title">Wie erfolgt die Auszahlung von Cashback?</string>
<string name="tangempay_cashback_accruals_subtitle">Grenzen und Ausnahmen</string>
<string name="tangempay_cashback_accruals_title">Abgrenzungen</string>
<string name="tangempay_cashback_additional_permanent">Dauerhaft</string>
<string name="tangempay_cashback_additional_title">Zusätzliches Cashback</string>
<string name="tangempay_cashback_additional_until">Bis %1$s</string>
<string name="tangempay_cashback_deactivated_description">Dies geschah aufgrund Ihres verdächtigen Verhaltens. Wenden Sie sich an den Support, um mehr zu erfahren.</string>
<string name="tangempay_cashback_deactivated_title">Cashback deaktiviert</string>
<string name="tangempay_cashback_deposited_on">Wird eingezahlt am %1$s</string>
<string name="tangempay_cashback_details_cap">%1$s maximal pro Monat</string>
<string name="tangempay_cashback_details_eu_excluded">Kein Cashback für Einkäufe vor Ort bei Händlern in der EU</string>
<string name="tangempay_cashback_details_paid_in">Bezahlt in %1$s</string>
<string name="tangempay_cashback_details_tier">%1$s%% Bei allen Einkäufen mit Ihren „ %2$s “-Karten gilt ein Mindestumsatz von %3$s</string>
<string name="tangempay_cashback_error_title">Die Seite konnte nicht geladen werden.\nZum Neuladen bitte antippen</string>
<string name="tangempay_cashback_refund_banner">Wir haben eine Rückerstattung für einen Kauf erhalten, für den zuvor bereits Cashback gewährt worden war</string>
<string name="tangempay_cashback_total_earned">%1$s insgesamt verdient</string>
<string name="tangempay_cashback_widget_title">%1$s Cashback in %2$s</string>
<string name="tangempay_change_pin_code">PIN-Code ändern</string>
<string name="tangempay_come_back_if_forget_pin">Kehren Sie zur App zurück, falls Sie ihn vergessen.</string>
<string name="tangempay_common_card">Karte</string>
<string name="tangempay_common_error_loading">Fehler beim Laden</string>
<string name="tangempay_current_plan_active_till_notification">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.</string>
<string name="tangempay_current_plan_change">Tarif wechseln</string>
<string name="tangempay_current_plan_fee_charged_notification">%1$s Die monatliche Gebühr wird am %2$s</string>
<string name="tangempay_current_plan_section_card">Kartenbezogen</string>
<string name="tangempay_current_plan_section_plan">Planbezogen</string>
<string name="tangempay_current_plan_stay_button">Bleib dran %1$s</string>
<string name="tangempay_current_plan_stay_sheet_body">Ihr Übergang auf „ %1$s “ wird storniert.</string>
<string name="tangempay_current_plan_stay_sheet_title">Möchtest du auf „ %1$s“ bleiben?</string>
<string name="tangempay_current_plan_title">Aktueller Plan</string>
<string name="tangempay_daily_limit_hint" formatted="false">Limit von %s bis %s festlegen</string>
<string name="tangempay_daily_limit_set_button">Limits festlegen</string>
@ -2046,7 +2077,16 @@
<string name="tangempay_select_plan_btn_select">Auswählen</string>
<string name="tangempay_select_plan_btn_upgrade">Tarif wechseln</string>
<string name="tangempay_select_plan_compare">Tarife vergleichen</string>
<string name="tangempay_select_plan_confirm_downgrade_title">Ihr „ %1$s “-Tarif und Ihre „ %2$s “-Karten sind gültig bis %3$s</string>
<string name="tangempay_select_plan_confirm_point_cancel_till">Sie können diesen Übergang bis zum %1$s</string>
<string name="tangempay_select_plan_confirm_point_cards_closed">Dein %1$s Die Karten werden geschlossen</string>
<string name="tangempay_select_plan_confirm_point_monthly_fee">%1$s Die monatliche Gebühr wird von Ihrem Konto abgebucht.</string>
<string name="tangempay_select_plan_confirm_point_move_on_date">Am %1$s werden wir Sie auf den Tarif „ %2$s “ umstellen.</string>
<string name="tangempay_select_plan_confirm_point_no_fee">Es fällt keine Gebühr an</string>
<string name="tangempay_select_plan_confirm_point_virtual_card">In wenigen Minuten erhalten Sie Ihre virtuelle „ %1$s “.</string>
<string name="tangempay_select_plan_confirm_switch_title">Sie wechseln zu %1$s</string>
<string name="tangempay_select_plan_confirm_title">Auswahl bestätigen</string>
<string name="tangempay_select_plan_confirm_upgrade_title">Wir stellen für Sie eine „ %1$s “ aus.</string>
<string name="tangempay_select_plan_title">Plan auswählen</string>
<string name="tangempay_service_unavailable_description">Wir beheben ein technisches Problem. Bitte versuchen Sie es später erneut.</string>
<string name="tangempay_service_unavailable_title">Service vorübergehend nicht verfügbar</string>

View file

@ -438,6 +438,7 @@
<string name="common_rename">Renombrar</string>
<string name="common_required">Requerido</string>
<string name="common_reset">Resetear</string>
<string name="common_retry">Reintentar</string>
<string name="common_save">Guarde</string>
<string name="common_save_changes">Guardar cambios</string>
<string name="common_search">Buscar</string>

View file

@ -416,6 +416,7 @@
<string name="common_rename">Renommer</string>
<string name="common_required">Obligatoire</string>
<string name="common_reset">Réinitialiser</string>
<string name="common_retry">Réessayer</string>
<string name="common_save">Enregistrez</string>
<string name="common_save_changes">Sauvegarder les modifications</string>
<string name="common_search">Rechercher</string>

View file

@ -427,6 +427,7 @@
<string name="common_rename">名前を変更</string>
<string name="common_required">必須</string>
<string name="common_reset">リセット</string>
<string name="common_retry">リトライ</string>
<string name="common_save">保存</string>
<string name="common_save_changes">変更内容を保存</string>
<string name="common_search">検索</string>

View file

@ -441,6 +441,7 @@
<string name="common_rename">Renomear</string>
<string name="common_required">Obrigatório</string>
<string name="common_reset">Reiniciar</string>
<string name="common_retry">Tentar novamente</string>
<string name="common_save">Salvar</string>
<string name="common_save_changes">Salvar alterações</string>
<string name="common_search">Procurar</string>
@ -1732,7 +1733,7 @@
<string name="support_chat_screen_title">Chat de suporte</string>
<string name="support_chat_share_logs_button">Anexar logs do aplicativo</string>
<string name="support_chat_swap_prefilled_message">Dados da operação SWAP:\nDe: %1$s %2$s\nPara: %3$s %4$s\nPor %5$s - %6$s</string>
<string name="support_selector_view_chat_button">Abra o e-mail</string>
<string name="support_selector_view_chat_button">Abra o chat</string>
<string name="support_selector_view_email_button">Abra o e-mail</string>
<string name="swap_approve_description">Ao aprovar, você permite que o contrato inteligente utilize seus tokens em transações futuras.</string>
<string name="swap_detailed_mode">Modo detalhado</string>

View file

@ -133,6 +133,7 @@
<string name="address_book_no_contacts">Нет добавленных контактов</string>
<string name="address_book_no_contacts_description">Здесь отобразятся добавленные вами контакты.</string>
<string name="address_book_remove_address">Удалить адрес</string>
<string name="address_book_save_address">Сохранить адрес</string>
<string name="address_book_save_contact">Сохранить контакт</string>
<string name="address_book_save_to_wallet_title">Сохранить в кошелек</string>
<string name="address_book_save_wallet_to_description">Этот контакт будет привязан к этому кошельку в адресной книге.</string>
@ -458,6 +459,7 @@
<string name="common_rename">Переименовать</string>
<string name="common_required">Требуется</string>
<string name="common_reset">Сброс</string>
<string name="common_retry">Повторить</string>
<string name="common_save">Сохранить</string>
<string name="common_save_changes">Сохранить изменения</string>
<string name="common_search">Поиск</string>

View file

@ -133,6 +133,7 @@
<string name="address_book_no_contacts">Немає доданих контактів</string>
<string name="address_book_no_contacts_description">Тут відображатимуться додані вами контакти.</string>
<string name="address_book_remove_address">Видалити адресу</string>
<string name="address_book_save_address">Зберегти адресу</string>
<string name="address_book_save_contact">Зберегти контакт</string>
<string name="address_book_save_to_wallet_title">Зберегти в гаманець</string>
<string name="address_book_save_wallet_to_description">Цей контакт буде прив\'язано до цього гаманця в адресній книзі.</string>
@ -458,6 +459,7 @@
<string name="common_rename">Перейменувати</string>
<string name="common_required">Обов\'язково</string>
<string name="common_reset">Скинути</string>
<string name="common_retry">Повторити</string>
<string name="common_save">Зберегти</string>
<string name="common_save_changes">Зберегти зміни</string>
<string name="common_search">Пошук</string>

View file

@ -426,6 +426,7 @@
<string name="common_rename">重命名</string>
<string name="common_required">必需的</string>
<string name="common_reset">重置</string>
<string name="common_retry">重试</string>
<string name="common_save">节省</string>
<string name="common_save_changes">保存更改</string>
<string name="common_search">搜索</string>

View file

@ -77,6 +77,7 @@
<string name="common_range">%1$s-%2$s</string>
<string name="common_reject">拒絕</string>
<string name="common_rename">重新命名</string>
<string name="common_retry">重試</string>
<string name="common_save_changes">保存設置</string>
<string name="common_search">搜索</string>
<string name="common_search_tokens">搜尋代幣</string>

View file

@ -441,6 +441,7 @@
<string name="common_rename">Rename</string>
<string name="common_required">Required</string>
<string name="common_reset">Reset</string>
<string name="common_retry">Retry</string>
<string name="common_save">Save</string>
<string name="common_save_changes">Save changes</string>
<string name="common_search">Search</string>
@ -741,6 +742,7 @@
<string name="force_update_button">Update</string>
<string name="force_update_os_description">Your operating system is out of date. Please update it to continue using the app.</string>
<string name="force_update_os_title">Update Your OS</string>
<string name="force_update_required_action">Update app</string>
<string name="force_update_warning_message">Please update the app to its latest version to ensure proper functionality.</string>
<string name="force_update_warning_title">Update required</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">Not enough funds</string>
@ -908,9 +910,11 @@
<item quantity="one">%d asset</item>
<item quantity="other">%d assets</item>
</plurals>
<string name="market_chart_bubble_no_amount">No amount on tokens</string>
<string name="market_chart_bubble_no_data">No data</string>
<string name="market_chart_bubble_total_value">Total value</string>
<string name="market_chart_can_not_load_data">Cant load data</string>
<string name="market_chart_no_amount">You dont have any tokens with amount</string>
<string name="market_chart_top_holding">Top holding %s</string>
<string name="markets_about_coin_header">About coin</string>
<string name="markets_add_to_my_portfolio_description">To buy, exchange, or receive this asset, add it to your portfolio</string>
@ -1930,9 +1934,35 @@
<item quantity="one">%d card</item>
<item quantity="other">%d cards</item>
</plurals>
<string name="tangempay_cashback_accruals_calc_description">We process purchases within 5 days after the operation and count only completed transactions</string>
<string name="tangempay_cashback_accruals_calc_title">How we calculate cashback?</string>
<string name="tangempay_cashback_accruals_exceptions_description">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</string>
<string name="tangempay_cashback_accruals_exceptions_title">Exceptions</string>
<string name="tangempay_cashback_accruals_pay_description">From the 2nd and the 5th of the next month</string>
<string name="tangempay_cashback_accruals_pay_title">How we pay cashback?</string>
<string name="tangempay_cashback_accruals_subtitle">Limits and exceptions</string>
<string name="tangempay_cashback_accruals_title">Accruals</string>
<string name="tangempay_cashback_additional_permanent">Permanent</string>
<string name="tangempay_cashback_additional_title">Additional cashback</string>
<string name="tangempay_cashback_additional_until">Until %1$s</string>
<string name="tangempay_cashback_deactivated_description">It was made due to your suspicious behavior. Contact support to learn more</string>
<string name="tangempay_cashback_deactivated_title">Cashback deactivated</string>
<string name="tangempay_cashback_deposit_banner">Cashback %1$s for %2$s will be deposited till %3$s</string>
<string name="tangempay_cashback_deposited_on">Will be deposited on %1$s</string>
<string name="tangempay_cashback_details_cap">%1$s max per month</string>
<string name="tangempay_cashback_details_eu_excluded">No cashback for in-person purchases at EU merchants</string>
<string name="tangempay_cashback_details_paid_in">Paid in %1$s</string>
<string name="tangempay_cashback_details_tier">%1$s%% for all purchases with your %2$s cards, min purchase %3$s</string>
<string name="tangempay_cashback_earned_title">%1$s earned in %2$s</string>
<string name="tangempay_cashback_empty_subtitle">Collected amount will be shown here</string>
<string name="tangempay_cashback_empty_title">Start spending\nand earn cashback</string>
<string name="tangempay_cashback_error_title">Failed to load page.\nTap to reload</string>
<string name="tangempay_cashback_rate_subtitle">With your %1$s plan</string>
<string name="tangempay_cashback_rate_title">Cashback %1$s%%</string>
<string name="tangempay_cashback_rate_title_up_to">Cashback up to %1$s%%</string>
<string name="tangempay_cashback_refund_banner">We received a refund for a purchase for which cashback had previously been awarded</string>
<string name="tangempay_cashback_title">Cashback</string>
<string name="tangempay_cashback_total_earned">%1$s earned in total</string>
<string name="tangempay_cashback_widget_title">%1$s cashback in %2$s</string>
<string name="tangempay_change_pin_code">Change PIN-code</string>
<string name="tangempay_come_back_if_forget_pin">Come back to the app if you forget it.</string>
@ -2091,8 +2121,11 @@
<string name="tangempay_topup_swap_body">Use crypto from your wallet to top up your payment account</string>
<string name="tangempay_topup_swap_title">From your Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC on Polygon network</string>
<string name="tangempay_va_account_details">Account details</string>
<string name="tangempay_va_available_to_deposit_day">Available to deposit per day:</string>
<string name="tangempay_va_banking_details_error_description">Please try again or contact support if the issue persists</string>
<string name="tangempay_va_banking_details_error_title">Couldn\'t load banking details</string>
<string name="tangempay_va_limit_resetting_everyday">Limit is resetting every day</string>
<string name="tangempay_visa_benefits">Visa Benefits</string>
<string name="tangempay_withdrawal_note_description">Funds from refunded purchases wont be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases</string>
<string name="tangempay_withdrawal_note_title">Please note</string>

View file

@ -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),
)
}
}

View file

@ -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,
}
}

View file

@ -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(

View file

@ -178,11 +178,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> 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)

View file

@ -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<TangemMessageBanner.Button?, TangemMessageBanner.Button?> =
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
}

View file

@ -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<Pair<Float, Color>> {
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<Pair<Float, Color>> = 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<Color>, magicBlend: List<Color>, mix: Float): List<Pair<Float, Color>> {
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<Color>): List<Pair<Float, Color>> {
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<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.MagicBlend.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Success.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Error.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Warning.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Info.steps(): List<Color> =
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,
)
}
}
}
}

View file

@ -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<Pair<Float, Color>>,
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<Pair<Float, Color>>,
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<Pair<Float, Color>>,
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<Pair<Float, Color>>,
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<Pair<Float, Color>>, deg: Float): Array<Pair<Float, Color>> {
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()
}

View file

@ -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,
)
}
}

View file

@ -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)),

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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)
}

View file

@ -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<Map<MarketingScreenType, List<MarketingCampaign>>>(emptyMap())
private val cacheMutex = Mutex()
override suspend fun getCampaigns(screen: MarketingScreen): Either<Throwable, List<MarketingCampaign>> =
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<Int> = dismissStore.getDismissedIds()
override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId)
private suspend fun loadCacheableByType(type: MarketingScreenType): List<MarketingCampaign> {
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<MarketingCampaign>? {
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<MarketingCampaignsResponse> {
return tangemTechApi.getMarketingCampaigns(
type = type.value,
language = SupportedLanguages.getCurrentSupportedLanguageCode(),
eTag = eTag,
)
}
private suspend fun requestCampaigns(
screen: MarketingScreen,
eTag: String?,
): ApiResponse<MarketingCampaignsResponse> {
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<MarketingCampaign> =
converter.convertListIgnoreErrors(response.campaigns) { throwable ->
TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}")
}
}

View file

@ -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<CampaignDto, MarketingCampaign> {
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
}
}
}

View file

@ -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<MarketingCampaignsCacheEntry>(),
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<Int>(),
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,
)
}

View file

@ -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)
}

View file

@ -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<Map<String, MarketingCampaignsCacheEntry>>,
) : 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) }
}
}

View file

@ -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<Int>
suspend fun dismiss(id: Int)
}
internal class DefaultMarketingDismissStore(
private val dataStore: DataStore<Set<Int>>,
) : MarketingDismissStore {
override suspend fun getDismissedIds(): Set<Int> = dataStore.data.first()
override suspend fun dismiss(id: Int) {
dataStore.updateData { it + id }
}
}

View file

@ -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<MarketingCampaignsResponse> = ApiResponse.Error(
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
) as ApiResponse<MarketingCampaignsResponse>
@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<Unit>()
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) }
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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<Map<String, MarketingCampaignsCacheEntry>>(default = emptyMap()),
)
private val dismissStore = DefaultMarketingDismissStore(
dataStore = MockStateDataStore<Set<Int>>(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)
}
}

View file

@ -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],
)
}

View file

@ -21,6 +21,7 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
params = MultiNetworkStatusFetcher.Params(
userWalletId = params.userWalletId,
networks = setOf(params.network),
extraTokens = params.extraTokens,
),
)
}

View file

@ -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
}

View file

@ -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<UserWalletId>,
): 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"
}
}

View file

@ -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),
),
)
}
}

View file

@ -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,
)
}
}

View file

@ -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<PromotionRegistrationResponse>
// 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<PromotionRegistrationResponse>
// 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<PromotionRegistrationResponse>
// Act
val error = runCatching { repository.enroll(campaign, tokenReward, listOf(userWalletId)) }.exceptionOrNull()
// Assert
assertThat(error).isNotNull()
}
}

View file

@ -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()
}
}

View file

@ -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 */

View file

@ -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<UserWallet>.addPaeraCustomersData(): List<UserWalletData> {
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() }
}

View file

@ -118,6 +118,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
},
error = null,
virtualAccount = null,
)
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE,

View file

@ -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<Throwable, VirtualAccountActivationData> {
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,

View file

@ -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<Throwable, VirtualAccountActivationData> =
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,

View file

@ -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,
)
}
}
}

View file

@ -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,
)
}
}

View file

@ -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
}
},
)
}

View file

@ -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<VisaApiError, BankCredentials> {
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<VisaApiError, String> = 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<VisaApiError, List<TangemPayEligibilityType>> {
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)
}

View file

@ -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 {

View file

@ -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<BankCredentialsResponse.Result, BankCredentials> {
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(),
)
}
}

View file

@ -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<CustomerMeResponse.Result, Cus
val name = displayName?.ifEmpty { null }
return ProductInstance(
id = id,
cardId = cardId,
cardId = cardId.orEmpty(),
frozenState = cardFrozenState,
status = status,
displayName = if (name != null) CardDisplayName(name).getOrElse { null } else null,
actualCardLimit = actualCardLimit?.parseCardLimit(),
adminCardLimit = adminCardLimit?.parseCardLimit(),
specificationDataType = specificationDataType.toDomain(),
)
}
@ -108,4 +110,10 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
CustomerMeResponse.ProductInstance.Status.CANCELED -> 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
}
}

View file

@ -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
}
}

View file

@ -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,
)
}
}
}

Some files were not shown because too many files have changed in this diff Show more