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` | | **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` | | **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**. > Mind the numbering mismatch: **folder `ds` is DS2**, **folder `ds2` is DS3**.
> The `colors2` / `typography2` tokens are `@Deprecated` (ReplaceWith `colors3` / `typography3`). > 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. - **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 - **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 1. **Package & location.** `com.tangem.core.ui.ds2.<component>`, folder
`core/ui/.../ds2/<component>/`. The component name is `Tangem<Name>`. `core/ui/.../ds2/<component>/`. The component name is `Tangem<Name>`.
2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`, 2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`. No
dimensions — `TangemTheme.dimens2.*`. No `colors` / `colors2` / hardcoded values (literal dp/colors `colors` / `colors2` and no hardcoded colors outside `@Preview`. **Dimensions have no DS3 token**
are acceptable only inside `@Preview`, where you add `@Suppress("MagicNumber")`). 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 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 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. `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>`. - [ ] Component created under `core/ui/.../ds2/<component>/`, package `com.tangem.core.ui.ds2.<component>`.
- [ ] Named `Tangem<Name>`; first optional parameter is `modifier: Modifier = Modifier`. - [ ] 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). - [ ] 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>`. - [ ] All public types (enums, statuses, constants) declared inside the `object Tangem<Name>`.
- [ ] Convenient overloads provided (simpler `@Composable` overloads and/or `object` extension presets). - [ ] 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.onramp)
implementation(projects.domain.stories) implementation(projects.domain.stories)
implementation(projects.domain.stories.models) implementation(projects.domain.stories.models)
implementation(projects.domain.marketing)
implementation(projects.domain.marketing.models)
implementation(projects.domain.networks) implementation(projects.domain.networks)
implementation(projects.domain.quotes) implementation(projects.domain.quotes)
implementation(projects.domain.notifications) implementation(projects.domain.notifications)
@ -164,6 +166,7 @@ dependencies {
implementation(projects.domain.walletManager) implementation(projects.domain.walletManager)
implementation(projects.domain.walletManager.models) implementation(projects.domain.walletManager.models)
implementation(projects.domain.yieldSupply) implementation(projects.domain.yieldSupply)
implementation(projects.domain.promo)
implementation(projects.domain.blockaid) implementation(projects.domain.blockaid)
implementation(projects.domain.hotWallet) implementation(projects.domain.hotWallet)
implementation(projects.domain.news) implementation(projects.domain.news)
@ -208,6 +211,7 @@ dependencies {
implementation(projects.data.transaction) implementation(projects.data.transaction)
implementation(projects.data.visa) implementation(projects.data.visa)
implementation(projects.data.stories) implementation(projects.data.stories)
implementation(projects.data.marketing)
implementation(projects.data.onboarding) implementation(projects.data.onboarding)
implementation(projects.data.dynamicAddresses) implementation(projects.data.dynamicAddresses)
implementation(projects.data.feedback) implementation(projects.data.feedback)
@ -225,6 +229,7 @@ dependencies {
implementation(projects.data.swap) implementation(projects.data.swap)
implementation(projects.data.walletManager) implementation(projects.data.walletManager)
implementation(projects.data.yieldSupply) implementation(projects.data.yieldSupply)
implementation(projects.data.promo)
implementation(projects.data.hotWallet) implementation(projects.data.hotWallet)
implementation(projects.data.news) implementation(projects.data.news)
implementation(projects.data.earn) implementation(projects.data.earn)
@ -235,6 +240,8 @@ dependencies {
/** Features */ /** Features */
implementation(projects.features.addressBook.api) implementation(projects.features.addressBook.api)
implementation(projects.features.addressBook.impl) implementation(projects.features.addressBook.impl)
implementation(projects.features.marketing.api)
implementation(projects.features.marketing.impl)
implementation(projects.features.rating.impl) implementation(projects.features.rating.impl)
implementation(projects.features.referral.impl) implementation(projects.features.referral.impl)
implementation(projects.features.referral.domain) implementation(projects.features.referral.domain)

View file

@ -313,6 +313,16 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <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 <data
android:host="news" android:host="news"
android:scheme="tangem" /> android:scheme="tangem" />
@ -337,6 +347,16 @@
android:host="yield" android:host="yield"
android:scheme="tangem" /> android:scheme="tangem" />
</intent-filter> </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"> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <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), "") 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) { override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) {
appPreferencesStore.store( appPreferencesStore.store(
PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), 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.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask 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.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler import com.tangem.tap.domain.visa.VisaCardScanHandler
import dagger.Module import dagger.Module
@ -31,6 +32,7 @@ internal class TangemSdkManagerModule {
visaCardScanHandler: VisaCardScanHandler, visaCardScanHandler: VisaCardScanHandler,
visaCardActivationTaskFactory: VisaCardActivationTask.Factory, visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles, onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
analyticsErrorHandler: AnalyticsErrorHandler, analyticsErrorHandler: AnalyticsErrorHandler,
cardRepository: CardRepository, cardRepository: CardRepository,
@ -44,6 +46,7 @@ internal class TangemSdkManagerModule {
visaCardScanHandler = visaCardScanHandler, visaCardScanHandler = visaCardScanHandler,
visaCardActivationTaskFactory = visaCardActivationTaskFactory, visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
tangemPayVirtualAccountTaskFactory = tangemPayVirtualAccountTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles, onboardingV2FeatureToggles = onboardingV2FeatureToggles,
analyticsErrorHandler = analyticsErrorHandler, analyticsErrorHandler = analyticsErrorHandler,
cardRepository = cardRepository, 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.common.analytics.paramsInterceptor.CardContextInterceptor
import com.tangem.tap.domain.tasks.product.* import com.tangem.tap.domain.tasks.product.*
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask 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.TangemPaySignWithdrawalHashTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask
@ -72,6 +73,7 @@ internal class DefaultTangemSdkManager(
private val visaCardScanHandler: VisaCardScanHandler, private val visaCardScanHandler: VisaCardScanHandler,
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val analyticsErrorHandler: AnalyticsErrorHandler, private val analyticsErrorHandler: AnalyticsErrorHandler,
private val cardRepository: CardRepository, 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( override suspend fun getWithdrawalSignature(
hash: String, hash: String,
preflightReadFilter: PreflightReadFilter, 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.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.visa.model.TangemPayInitialCredentials 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.VisaActivationInput
import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
@ -241,6 +242,12 @@ class MockTangemSdkManager(
error("Not implemented") error("Not implemented")
} }
override suspend fun tangemPayProduceVirtualAccountData(
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, VirtualAccountActivationData> {
error("Not implemented")
}
override suspend fun getWithdrawalSignature( override suspend fun getWithdrawalSignature(
hash: String, hash: String,
preflightReadFilter: PreflightReadFilter, 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.UserWalletsListRepository.LockMethod
import com.tangem.domain.common.wallets.error.* import com.tangem.domain.common.wallets.error.*
import com.tangem.domain.hotwallet.repository.HotWalletRepository 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.models.wallet.*
import com.tangem.domain.wallets.R import com.tangem.domain.wallets.R
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
@ -127,6 +129,8 @@ internal class DefaultUserWalletsListRepository(
canOverride: Boolean, canOverride: Boolean,
): Either<SaveWalletError, UserWallet> = either { ): Either<SaveWalletError, UserWallet> = either {
if (canOverride.not() && userWallets.value?.any { it.walletId == userWallet.walletId } == true) { 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)) raise(SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved))
} }
@ -322,6 +326,8 @@ internal class DefaultUserWalletsListRepository(
raise(UnlockWalletError.ScannedCardWalletNotMatched) raise(UnlockWalletError.ScannedCardWalletNotMatched)
} }
refreshStoredCardState(scanResponse)
val encryptionKey = UserWalletEncryptionKey( val encryptionKey = UserWalletEncryptionKey(
walletId = userWallet.walletId, walletId = userWallet.walletId,
encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty), 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( private suspend fun checkForUpgradeAndDeleteHotWalletIfNeeded(
newUserWallet: UserWallet, newUserWallet: UserWallet,
oldUserWallet: UserWallet, oldUserWallet: UserWallet,

View file

@ -42,6 +42,7 @@ internal fun RootContent(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
wcContent: @Composable (modifier: Modifier) -> Unit, wcContent: @Composable (modifier: Modifier) -> Unit,
promoContent: @Composable (modifier: Modifier) -> Unit,
hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit, hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit,
rootDetectedWarningContent: @Composable (modifier: Modifier) -> Unit, rootDetectedWarningContent: @Composable (modifier: Modifier) -> Unit,
scanFailsContent: @Composable (modifier: Modifier) -> Unit, scanFailsContent: @Composable (modifier: Modifier) -> Unit,
@ -80,6 +81,8 @@ internal fun RootContent(
wcContent(Modifier.fillMaxSize()) wcContent(Modifier.fillMaxSize())
promoContent(Modifier.fillMaxSize())
hotAccessCodeContent(Modifier.fillMaxSize()) hotAccessCodeContent(Modifier.fillMaxSize())
rootDetectedWarningContent(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.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION 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.features.walletconnect.components.WcRoutingComponent
import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.android.create import com.tangem.hot.sdk.android.create
@ -83,6 +84,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val appRouterConfig: AppRouterConfig, private val appRouterConfig: AppRouterConfig,
private val uiDependencies: UiDependencies, private val uiDependencies: UiDependencies,
private val wcRoutingComponentFactory: WcRoutingComponent.Factory, private val wcRoutingComponentFactory: WcRoutingComponent.Factory,
private val campaignsComponentFactory: CampaignsComponent.Factory,
private val deeplinkFactory: DeepLinkFactory, private val deeplinkFactory: DeepLinkFactory,
private val tangemHotSDKProxy: TangemHotSDKProxy, private val tangemHotSDKProxy: TangemHotSDKProxy,
private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory, private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory,
@ -112,6 +114,11 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
.create(child("wcRoutingComponent"), params = Unit) .create(child("wcRoutingComponent"), params = Unit)
} }
private val campaignsComponent: CampaignsComponent by lazy {
campaignsComponentFactory
.create(child("swapCashbackCampaign"), params = Unit)
}
private val hotAccessCodeRequestComponent: HotAccessCodeRequestComponent by lazy { private val hotAccessCodeRequestComponent: HotAccessCodeRequestComponent by lazy {
hotAccessCodeRequestComponentFactory hotAccessCodeRequestComponentFactory
.create(child("hotAccessCodeRequestComponent"), Unit) .create(child("hotAccessCodeRequestComponent"), Unit)
@ -278,6 +285,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
onBack = router::pop, onBack = router::pop,
modifier = modifier, modifier = modifier,
wcContent = { wcRoutingComponent.Content(it) }, wcContent = { wcRoutingComponent.Content(it) },
promoContent = { campaignsComponent.Content(it) },
hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) }, hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) },
rootDetectedWarningContent = { rootDetectedWarningComponent.Content(it) }, rootDetectedWarningContent = { rootDetectedWarningComponent.Content(it) },
scanFailsContent = { scanFailsComponent.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
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.* import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.*
import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent
import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.wallet.WalletEntryComponent
import com.tangem.features.walletconnect.components.WalletConnectEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent
import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent
@ -114,6 +115,7 @@ internal class ChildFactory @Inject constructor(
private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory, private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory,
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory, private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory,
private val virtualAccountOnboardingComponentFactory: VirtualAccountOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory, private val kycComponentFactory: KycComponent.Factory,
private val surveyComponentFactory: SurveyComponent.Factory, private val surveyComponentFactory: SurveyComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
@ -703,6 +705,23 @@ internal class ChildFactory @Inject constructor(
componentFactory = tangemPayWalletOnboardingComponentFactory, 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 -> { is AppRoute.Kyc -> {
createComponentChild( createComponentChild(
context = context, 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.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler 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.send.api.deeplink.SellRedirectDeepLinkHandler
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler 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.tangempay.deeplink.TangemPayMainDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
@ -57,6 +59,7 @@ internal class DeepLinkFactory @Inject constructor(
private val swapDeepLink: SwapDeepLinkHandler.Factory, private val swapDeepLink: SwapDeepLinkHandler.Factory,
private val promoDeepLink: PromoDeeplinkHandler.Factory, private val promoDeepLink: PromoDeeplinkHandler.Factory,
private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory,
private val onboardVirtualAccountsDeepLink: OnboardVirtualAccountsDeepLinkHandler.Factory,
private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory, private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory,
private val tangemPayMainDeepLink: TangemPayMainDeepLinkHandler.Factory, private val tangemPayMainDeepLink: TangemPayMainDeepLinkHandler.Factory,
private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory,
@ -64,6 +67,7 @@ internal class DeepLinkFactory @Inject constructor(
private val earnDeepLink: EarnDeepLinkHandler.Factory, private val earnDeepLink: EarnDeepLinkHandler.Factory,
private val yieldDeepLink: YieldDeepLinkHandler.Factory, private val yieldDeepLink: YieldDeepLinkHandler.Factory,
private val surveyDeepLink: SurveyDeepLinkHandler.Factory, private val surveyDeepLink: SurveyDeepLinkHandler.Factory,
private val promoCampaignsDeepLink: CampaignsDeepLinkHandler.Factory,
) { ) {
private val permittedAppRoute = MutableStateFlow(false) private val permittedAppRoute = MutableStateFlow(false)
@ -173,11 +177,13 @@ internal class DeepLinkFactory @Inject constructor(
DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri) DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri)
DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri) DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri)
DeepLinkRoute.OnboardVirtualAccounts.host -> onboardVirtualAccountsDeepLink.create(deeplinkUri)
DeepLinkRoute.News.host -> newsDeepLink.create(queryParams) DeepLinkRoute.News.host -> newsDeepLink.create(queryParams)
DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams) DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams)
DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams) DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams)
DeepLinkRoute.Campaigns.host -> promoCampaignsDeepLink.create(queryParams)
else -> { else -> {
TangemLogger.i( TangemLogger.i(
""" """

View file

@ -81,6 +81,15 @@ internal class MockAwareTangemPayStorage @Inject constructor(
override suspend fun clearOrderId(customerWalletAddress: String) = override suspend fun clearOrderId(customerWalletAddress: String) =
real.clearOrderId(customerWalletAddress) 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) = override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) =
real.storeCheckCustomerWalletResult(userWalletId, isPaeraCustomer) 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.google.common.truth.Truth.assertThat
import com.tangem.common.CompletionResult import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError 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.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase 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.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.hotwallet.repository.HotWalletRepository 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.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
@ -144,4 +149,162 @@ internal class DefaultUserWalletsListRepositoryTest {
assertThat(result.isLeft()).isTrue() assertThat(result.isLeft()).isTrue()
verify(exactly = 0) { trackingContextProxy.eraseContext() } 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.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler 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.send.api.deeplink.SellRedirectDeepLinkHandler
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler 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.tangempay.deeplink.TangemPayMainDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
@ -84,6 +86,10 @@ class DeepLinkFactoryTest {
every { create(any()) } returns mockk() 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) { private val tangemPayMainDeepLink = mockk<TangemPayMainDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk() every { create(any(), any()) } returns mockk()
} }
@ -112,6 +118,10 @@ class DeepLinkFactoryTest {
every { create(any(), any()) } returns mockk() every { create(any(), any()) } returns mockk()
} }
private val campaignsDeepLinkHandlerFactory = mockk<CampaignsDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val marketsTokenExchangesDeepLinkFactory = private val marketsTokenExchangesDeepLinkFactory =
mockk<MarketsTokenExchangesDeepLinkHandler.Factory>(relaxed = true) { mockk<MarketsTokenExchangesDeepLinkHandler.Factory>(relaxed = true) {
every { create(any(), any()) } returns mockk() every { create(any(), any()) } returns mockk()
@ -140,12 +150,14 @@ class DeepLinkFactoryTest {
swapDeepLink = swapDeepLinkFactory, swapDeepLink = swapDeepLinkFactory,
promoDeepLink = promoDeepLinkFactory, promoDeepLink = promoDeepLinkFactory,
onboardVisaDeepLink = onboardVisaDeepLink, onboardVisaDeepLink = onboardVisaDeepLink,
onboardVirtualAccountsDeepLink = onboardVirtualAccountsDeepLink,
tangemPayMainDeepLink = tangemPayMainDeepLink, tangemPayMainDeepLink = tangemPayMainDeepLink,
newsDetailsDeepLink = newsDeeplink, newsDetailsDeepLink = newsDeeplink,
newsDeepLink = newsDeepLinkFactory, newsDeepLink = newsDeepLinkFactory,
earnDeepLink = earnDeepLinkFactory, earnDeepLink = earnDeepLinkFactory,
yieldDeepLink = yieldDeepLinkFactory, yieldDeepLink = yieldDeepLinkFactory,
surveyDeepLink = surveyDeepLinkFactory, surveyDeepLink = surveyDeepLinkFactory,
promoCampaignsDeepLink = campaignsDeepLinkHandlerFactory,
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)

View file

@ -509,6 +509,24 @@ sealed class AppRoute(val path: String) : Route {
@Serializable @Serializable
data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc") 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 @Serializable
data class Survey(val token: String, val displayId: String? = null) : AppRoute(path = "/survey") 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" override val host: String = "onboard-visa"
} }
data object OnboardVirtualAccounts : DeepLinkRoute() {
override val host: String = "onboard-virtual-account"
}
data object PayApp : DeepLinkRoute() { data object PayApp : DeepLinkRoute() {
override val host: String = "tangem.com" override val host: String = "tangem.com"
} }
@ -91,6 +95,10 @@ sealed class DeepLinkRoute {
data object Survey : DeepLinkRoute() { data object Survey : DeepLinkRoute() {
override val host: String = "survey" override val host: String = "survey"
} }
data object Campaigns : DeepLinkRoute() {
override val host: String = "campaigns"
}
} }
enum class DeepLinkScheme(val scheme: String) { enum class DeepLinkScheme(val scheme: String) {

View file

@ -16,6 +16,7 @@ object DeeplinkConst {
const val PROMO_CODE_KEY = "promo_code" const val PROMO_CODE_KEY = "promo_code"
const val REF_KEY = "ref" const val REF_KEY = "ref"
const val CAMPAIGN_KEY = "campaign" const val CAMPAIGN_KEY = "campaign"
const val CAMPAIGN_ID_KEY = "campaignId"
const val NAME_KEY = "name" const val NAME_KEY = "name"
const val ORDER_KEY = "order" const val ORDER_KEY = "order"
const val INTERVAL_KEY = "interval" 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", "name": "AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED",
"version": "6.0" "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") @GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse> 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}") @GET("v1/customer/wallets/{customer_wallet_id}")
suspend fun checkCustomerWalletId( suspend fun checkCustomerWalletId(
@Path("customer_wallet_id") customerWalletId: String, @Path("customer_wallet_id") customerWalletId: String,
@ -40,6 +47,12 @@ interface TangemPayApi {
@GET("v1/eligibility/channels") @GET("v1/eligibility/channels")
suspend fun getEligibilityChannels(): ApiResponse<TangemPayEligibilityChannels> 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}") @GET("v1/order/{order_id}")
suspend fun getOrder( suspend fun getOrder(
@Header("Authorization") authHeader: String, @Header("Authorization") authHeader: String,
@ -64,6 +77,13 @@ interface TangemPayApi {
@Body body: OrderRequest, @Body body: OrderRequest,
): ApiResponse<OrderResponse> ): 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. */ /** Customer offers — used to gate the issue-additional-card flow. */
@GET("v1/customer/offers") @GET("v1/customer/offers")
suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse<CustomerOffersResponse> 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( data class ProductInstance(
@Json(name = "id") val id: String, @Json(name = "id") val id: String,
@Json(name = "cid") val cid: 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 = "card_wallet_address") val cardWalletAddress: String?,
@Json(name = "status") val status: Status, @Json(name = "status") val status: Status,
@Json(name = "updated_at") val updatedAt: String, @Json(name = "updated_at") val updatedAt: String,
@ -35,7 +35,17 @@ data class CustomerMeResponse(
@Json(name = "display_name") val displayName: String?, @Json(name = "display_name") val displayName: String?,
@Json(name = "actual_card_limit") val actualCardLimit: CardLimit?, @Json(name = "actual_card_limit") val actualCardLimit: CardLimit?,
@Json(name = "admin_card_limit") val adminCardLimit: 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) @JsonClass(generateAdapter = false)
enum class Status { enum class Status {
@Json(name = "NEW") @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) @JsonClass(generateAdapter = true)
data class PromoToken( data class PromoToken(
@Json(name = "tokenId") val tokenId: String,
@Json(name = "tokenAddress") val tokenAddress: String, @Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "tokenSymbol") val tokenSymbol: String, @Json(name = "tokenSymbol") val tokenSymbol: String,
@Json(name = "tokenName") val tokenName: String, @Json(name = "tokenName") val tokenName: String,
@Json(name = "networkId") val networkId: 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 package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.api.common.response.ApiResponse 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.PromotionsResponse
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
import com.tangem.datasource.api.stories.models.StoryContentResponse import com.tangem.datasource.api.stories.models.StoryContentResponse
@ -120,7 +123,7 @@ interface TangemTechApi {
@GET("v1/stories/{story_id}") @GET("v1/stories/{story_id}")
suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse> suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse>
// region yield-boost promo // region promotions
@GET("/v2/promotion") @GET("/v2/promotion")
suspend fun getPromotions( suspend fun getPromotions(
@Query("walletId") walletId: String, @Query("walletId") walletId: String,
@ -130,6 +133,11 @@ interface TangemTechApi {
@Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite") @Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite")
@GET("/v2/promotion/yield-apr-boost/status") @GET("/v2/promotion/yield-apr-boost/status")
suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse<YieldBoostStatusResponse> suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse<YieldBoostStatusResponse>
@POST("/v2/promotion/registrations")
suspend fun createPromotionRegistration(
@Body body: CreatePromotionRegistrationBody,
): ApiResponse<PromotionRegistrationResponse>
// endregion // endregion
// region push notifications // region push notifications
@ -235,4 +243,18 @@ interface TangemTechApi {
@GET("v1/earn/networks") @GET("v1/earn/networks")
suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse<EarnNetworkListResponse> suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse<EarnNetworkListResponse>
// endregion // 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, apiKey = GeneratedEnvironmentConfig.quiknodeMonadApiKey,
subdomain = GeneratedEnvironmentConfig.quiknodeMonadSubdomain, subdomain = GeneratedEnvironmentConfig.quiknodeMonadSubdomain,
), ),
quickNodeHederaCredentials = QuickNodeCredentials(
apiKey = GeneratedEnvironmentConfig.quiknodeHederaApiKey,
subdomain = GeneratedEnvironmentConfig.quiknodeHederaSubdomain,
),
infuraProjectId = GeneratedEnvironmentConfig.infuraProjectId, infuraProjectId = GeneratedEnvironmentConfig.infuraProjectId,
tronGridApiKey = GeneratedEnvironmentConfig.tronGridApiKey, tronGridApiKey = GeneratedEnvironmentConfig.tronGridApiKey,
nowNodeCredentials = NowNodeCredentials(apiKey = GeneratedEnvironmentConfig.nowNodesApiKey), nowNodeCredentials = NowNodeCredentials(apiKey = GeneratedEnvironmentConfig.nowNodesApiKey),

View file

@ -154,7 +154,7 @@ object PreferencesKeys {
val TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY by lazy { val TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY by lazy {
stringPreferencesKey(name = "tangemPayActiveWithdrawOrdersKey") 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") fun getShouldShowNotificationKey(key: String) = booleanPreferencesKey("showShowNotificationUM_$key")
// endregion // endregion
@ -189,6 +189,9 @@ object PreferencesKeys {
fun getTangemPayOrderIdKey(customerWalletAddress: String) = fun getTangemPayOrderIdKey(customerWalletAddress: String) =
stringPreferencesKey("tangem_pay_order_id_key_$customerWalletAddress") stringPreferencesKey("tangem_pay_order_id_key_$customerWalletAddress")
fun getTangemPayVirtualAccountOrderIdKey(customerWalletAddress: String) =
stringPreferencesKey("tangem_pay_va_order_id_key_$customerWalletAddress")
fun getTangemPayCustomerWalletAddressKey(userWalletId: UserWalletId) = fun getTangemPayCustomerWalletAddressKey(userWalletId: UserWalletId) =
stringPreferencesKey("tangem_pay_customer_wallet_address_key_${userWalletId.stringValue}") 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 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 getAddToWalletDone(customerWalletAddress: String): Boolean
suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: 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_rename">Umbenennen</string>
<string name="common_required">Erforderlich</string> <string name="common_required">Erforderlich</string>
<string name="common_reset">Zurücksetzen</string> <string name="common_reset">Zurücksetzen</string>
<string name="common_retry">Wiederholen</string>
<string name="common_save">Speichern</string> <string name="common_save">Speichern</string>
<string name="common_save_changes">Änderungen speichern</string> <string name="common_save_changes">Änderungen speichern</string>
<string name="common_search">Suchen</string> <string name="common_search">Suchen</string>
@ -907,9 +908,11 @@
<item quantity="one">Vermögenswert</item> <item quantity="one">Vermögenswert</item>
<item quantity="other">Vermögenswerte</item> <item quantity="other">Vermögenswerte</item>
</plurals> </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_no_data">Keine Daten</string>
<string name="market_chart_bubble_total_value">Gesamtwert</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_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="market_chart_top_holding">Top-Halterung %s</string>
<string name="markets_about_coin_header">Über diesen Coin</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> <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">Guthaben hinzufügen</string>
<string name="tangempay_card_details_add_funds_subtitle">Aufladeoptionen</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_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_card_number">Kartennummer</string>
<string name="tangempay_card_details_change_pin">PIN-Code</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> <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_rename_card_placeholder">Kartenname</string>
<string name="tangempay_card_details_reveal_text">Aufdecken</string> <string name="tangempay_card_details_reveal_text">Aufdecken</string>
<string name="tangempay_card_details_show_details">Kartendetails</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_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_unable_to_rename_card_description">Bitte versuche es später noch einmal.</string>
<string name="tangempay_card_details_unfreeze_card">Karte entsperren</string> <string name="tangempay_card_details_unfreeze_card">Karte entsperren</string>
@ -1924,17 +1932,40 @@
<item quantity="one">%d Karte</item> <item quantity="one">%d Karte</item>
<item quantity="other">%d Karten</item> <item quantity="other">%d Karten</item>
</plurals> </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_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_deactivated_title">Cashback deaktiviert</string>
<string name="tangempay_cashback_deposited_on">Wird eingezahlt am %1$s</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_cashback_widget_title">%1$s Cashback in %2$s</string>
<string name="tangempay_change_pin_code">PIN-Code ändern</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_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_card">Karte</string>
<string name="tangempay_common_error_loading">Fehler beim Laden</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_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_card">Kartenbezogen</string>
<string name="tangempay_current_plan_section_plan">Planbezogen</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_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_hint" formatted="false">Limit von %s bis %s festlegen</string>
<string name="tangempay_daily_limit_set_button">Limits 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_select">Auswählen</string>
<string name="tangempay_select_plan_btn_upgrade">Tarif wechseln</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_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_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_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_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> <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_rename">Renombrar</string>
<string name="common_required">Requerido</string> <string name="common_required">Requerido</string>
<string name="common_reset">Resetear</string> <string name="common_reset">Resetear</string>
<string name="common_retry">Reintentar</string>
<string name="common_save">Guarde</string> <string name="common_save">Guarde</string>
<string name="common_save_changes">Guardar cambios</string> <string name="common_save_changes">Guardar cambios</string>
<string name="common_search">Buscar</string> <string name="common_search">Buscar</string>

View file

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

View file

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

View file

@ -441,6 +441,7 @@
<string name="common_rename">Renomear</string> <string name="common_rename">Renomear</string>
<string name="common_required">Obrigatório</string> <string name="common_required">Obrigatório</string>
<string name="common_reset">Reiniciar</string> <string name="common_reset">Reiniciar</string>
<string name="common_retry">Tentar novamente</string>
<string name="common_save">Salvar</string> <string name="common_save">Salvar</string>
<string name="common_save_changes">Salvar alterações</string> <string name="common_save_changes">Salvar alterações</string>
<string name="common_search">Procurar</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_screen_title">Chat de suporte</string>
<string name="support_chat_share_logs_button">Anexar logs do aplicativo</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_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="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_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> <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">Нет добавленных контактов</string>
<string name="address_book_no_contacts_description">Здесь отобразятся добавленные вами контакты.</string> <string name="address_book_no_contacts_description">Здесь отобразятся добавленные вами контакты.</string>
<string name="address_book_remove_address">Удалить адрес</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_contact">Сохранить контакт</string>
<string name="address_book_save_to_wallet_title">Сохранить в кошелек</string> <string name="address_book_save_to_wallet_title">Сохранить в кошелек</string>
<string name="address_book_save_wallet_to_description">Этот контакт будет привязан к этому кошельку в адресной книге.</string> <string name="address_book_save_wallet_to_description">Этот контакт будет привязан к этому кошельку в адресной книге.</string>
@ -458,6 +459,7 @@
<string name="common_rename">Переименовать</string> <string name="common_rename">Переименовать</string>
<string name="common_required">Требуется</string> <string name="common_required">Требуется</string>
<string name="common_reset">Сброс</string> <string name="common_reset">Сброс</string>
<string name="common_retry">Повторить</string>
<string name="common_save">Сохранить</string> <string name="common_save">Сохранить</string>
<string name="common_save_changes">Сохранить изменения</string> <string name="common_save_changes">Сохранить изменения</string>
<string name="common_search">Поиск</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">Немає доданих контактів</string>
<string name="address_book_no_contacts_description">Тут відображатимуться додані вами контакти.</string> <string name="address_book_no_contacts_description">Тут відображатимуться додані вами контакти.</string>
<string name="address_book_remove_address">Видалити адресу</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_contact">Зберегти контакт</string>
<string name="address_book_save_to_wallet_title">Зберегти в гаманець</string> <string name="address_book_save_to_wallet_title">Зберегти в гаманець</string>
<string name="address_book_save_wallet_to_description">Цей контакт буде прив\'язано до цього гаманця в адресній книзі.</string> <string name="address_book_save_wallet_to_description">Цей контакт буде прив\'язано до цього гаманця в адресній книзі.</string>
@ -458,6 +459,7 @@
<string name="common_rename">Перейменувати</string> <string name="common_rename">Перейменувати</string>
<string name="common_required">Обов\'язково</string> <string name="common_required">Обов\'язково</string>
<string name="common_reset">Скинути</string> <string name="common_reset">Скинути</string>
<string name="common_retry">Повторити</string>
<string name="common_save">Зберегти</string> <string name="common_save">Зберегти</string>
<string name="common_save_changes">Зберегти зміни</string> <string name="common_save_changes">Зберегти зміни</string>
<string name="common_search">Пошук</string> <string name="common_search">Пошук</string>

View file

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

View file

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

View file

@ -441,6 +441,7 @@
<string name="common_rename">Rename</string> <string name="common_rename">Rename</string>
<string name="common_required">Required</string> <string name="common_required">Required</string>
<string name="common_reset">Reset</string> <string name="common_reset">Reset</string>
<string name="common_retry">Retry</string>
<string name="common_save">Save</string> <string name="common_save">Save</string>
<string name="common_save_changes">Save changes</string> <string name="common_save_changes">Save changes</string>
<string name="common_search">Search</string> <string name="common_search">Search</string>
@ -741,6 +742,7 @@
<string name="force_update_button">Update</string> <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_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_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_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="force_update_warning_title">Update required</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">Not enough funds</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="one">%d asset</item>
<item quantity="other">%d assets</item> <item quantity="other">%d assets</item>
</plurals> </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_no_data">No data</string>
<string name="market_chart_bubble_total_value">Total value</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_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="market_chart_top_holding">Top holding %s</string>
<string name="markets_about_coin_header">About coin</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> <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="one">%d card</item>
<item quantity="other">%d cards</item> <item quantity="other">%d cards</item>
</plurals> </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_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_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_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_cashback_widget_title">%1$s cashback in %2$s</string>
<string name="tangempay_change_pin_code">Change PIN-code</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> <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_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_topup_swap_title">From your Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC on Polygon network</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_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_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_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_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> <string name="tangempay_withdrawal_note_title">Please note</string>

View file

@ -323,7 +323,9 @@ fun BoxScope.FooterOverlay(
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
) { ) {
if (gradientHeight > 0.dp) { val isGradientDisplayed = gradientHeight > 0.dp
if (isGradientDisplayed) {
Fade( Fade(
backgroundColor = fadeMax, backgroundColor = fadeMax,
height = gradientHeight, height = gradientHeight,
@ -333,7 +335,7 @@ fun BoxScope.FooterOverlay(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(measuredFooterHeight ?: 0.dp) .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, var backgroundType: BackgroundType = BackgroundType.Unspecified,
) : Element { ) : Element {
enum class Type { enum class Type {
Unspecified, Accent, Informative, Attention, Warning, Unspecified, Accent, Informative, Attention, Warning, Success,
} }
enum class BackgroundType { 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.Informative -> TangemTheme.colors3.icon.status.info
MessageBottomSheetUM.Vector.Type.Attention -> TangemTheme.colors3.icon.status.warning MessageBottomSheetUM.Vector.Type.Attention -> TangemTheme.colors3.icon.status.warning
MessageBottomSheetUM.Vector.Type.Warning -> TangemTheme.colors3.icon.status.error MessageBottomSheetUM.Vector.Type.Warning -> TangemTheme.colors3.icon.status.error
MessageBottomSheetUM.Vector.Type.Success -> TangemTheme.colors3.icon.status.success
} }
val backgroundColor = when (vector.backgroundType) { 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.Informative -> TangemTheme.colors3.bg.status.infoSubtle
MessageBottomSheetUM.Vector.BackgroundType.Attention -> TangemTheme.colors3.bg.status.warningSubtle MessageBottomSheetUM.Vector.BackgroundType.Attention -> TangemTheme.colors3.bg.status.warningSubtle
MessageBottomSheetUM.Vector.BackgroundType.Warning -> TangemTheme.colors3.bg.status.errorSubtle MessageBottomSheetUM.Vector.BackgroundType.Warning -> TangemTheme.colors3.bg.status.errorSubtle
MessageBottomSheetUM.Vector.BackgroundType.Success -> TangemTheme.colors3.bg.status.successSubtle
} }
Box( Box(

View file

@ -178,11 +178,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
val isKeyboardOpen by rememberIsKeyboardVisible() val isKeyboardOpen by rememberIsKeyboardVisible()
val buttonHeight by animateDpAsState( val buttonHeight by animateDpAsState(
if (footer != null) { targetValue = if (footer != null) 80.dp else 0.dp,
80.dp
} else {
0.dp
},
) )
// Offset calculation for keyboard scroll adjustment: // Offset calculation for keyboard scroll adjustment:
// 1) Button height (footer) // 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 formattedAmount = formatter.format(value)
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator 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( combinedReference(
stringReference(formattedAmount.take(separatorIndex)), stringReference(formattedAmount.take(separatorIndex)),
@ -165,8 +166,9 @@ fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefe
cryptoCurrencySymbol = symbol, cryptoCurrencySymbol = symbol,
) )
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it).takeIf { i -> i >= 0 } }
?: formattedAmount.length
combinedReference( combinedReference(
stringReference(formattedAmount.take(separatorIndex)), stringReference(formattedAmount.take(separatorIndex)),

View file

@ -97,7 +97,7 @@ fun BigDecimalFiatFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefere
value.zeroIfRoundsToZero(FIAT_MARKET_DEFAULT_DIGITS) 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 currencySymbol = formatterCurrency.getSymbol(locale)
val rawFormatted = formatter.format(formattingAmount) val rawFormatted = formatter.format(formattingAmount)
@ -200,7 +200,7 @@ private fun BigDecimalFiatFormatStyled.price(spanStyleReference: SpanStyleRefere
roundingMode = RoundingMode.HALF_UP roundingMode = RoundingMode.HALF_UP
} }
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
val currencySymbol = formatterCurrency.getSymbol(locale) val currencySymbol = formatterCurrency.getSymbol(locale)
val rawFormatted = formatter.format(priceAmount) val rawFormatted = formatter.format(priceAmount)

View file

@ -190,11 +190,13 @@ object TangemTheme {
@ReadOnlyComposable @ReadOnlyComposable
get() = LocalTangemTypography3.current get() = LocalTangemTypography3.current
@Deprecated("Use plain dp")
val dimens: TangemDimens val dimens: TangemDimens
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
get() = LocalTangemDimens.current get() = LocalTangemDimens.current
@Deprecated("Use plain dp")
val dimens2: TangemDimens2 val dimens2: TangemDimens2
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable

View file

@ -1,8 +1,13 @@
package com.tangem.core.ui.format.bigdecimal package com.tangem.core.ui.format.bigdecimal
import androidx.compose.ui.text.SpanStyle
import com.google.common.truth.Truth 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.api.Test
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale import java.util.Locale
internal class BigDecimalCryptoFormatTest { internal class BigDecimalCryptoFormatTest {
@ -10,6 +15,7 @@ internal class BigDecimalCryptoFormatTest {
private val testLocale = Locale.US private val testLocale = Locale.US
private val testLocale2 = Locale.GERMANY private val testLocale2 = Locale.GERMANY
private val symbol = "BTC" private val symbol = "BTC"
private val spanStyleStub = SpanStyleReference { SpanStyle() }
// === defaultAmount() === // === defaultAmount() ===
@ -125,6 +131,39 @@ internal class BigDecimalCryptoFormatTest {
.isEqualTo("12,345,678.11".addSymbolWithSpaceLeft(symbol)) .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() === // === shorted() ===
@Test @Test

View file

@ -1,11 +1,16 @@
package com.tangem.core.ui.format.bigdecimal package com.tangem.core.ui.format.bigdecimal
import androidx.compose.ui.text.SpanStyle
import com.google.common.truth.Truth 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.api.Test
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale import java.util.Locale
internal class BigDecimalFiatFormatTest { internal class BigDecimalFiatFormatTest {
@ -16,6 +21,8 @@ internal class BigDecimalFiatFormatTest {
val usdCurrencyCode = "USD" val usdCurrencyCode = "USD"
val usdSymbol = "$" val usdSymbol = "$"
private val spanStyleStub = SpanStyleReference { SpanStyle() }
private fun String.addUsdSymbolLeft() = usdSymbol + this private fun String.addUsdSymbolLeft() = usdSymbol + this
// === defaultAmount() === // === defaultAmount() ===
@ -132,6 +139,40 @@ internal class BigDecimalFiatFormatTest {
.isEqualTo("-" + "0.01".addUsdSymbolLeft()) .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() === // === approximateAmount() ===
@Test @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. 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 ## Updating tokens
> **Note:** You only need `git submodule update --remote` when you want to pull **new** design 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 > 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, > 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. > 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. > 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: 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( commonNetworkStatusFetcher.fetch(
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
network = network, network = network,
networkCurrencies = networksCurrencies[network].orEmpty().toSet(), networkCurrencies = networksCurrencies[network].orEmpty().toSet() + params.extraTokens,
xpub = xpubByNetwork[network], xpub = xpubByNetwork[network],
) )
} }

View file

@ -21,6 +21,7 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
params = MultiNetworkStatusFetcher.Params( params = MultiNetworkStatusFetcher.Params(
userWalletId = params.userWalletId, userWalletId = params.userWalletId,
networks = setOf(params.network), 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 */ /** Project - Domain */
implementation(projects.domain.visa) implementation(projects.domain.visa)
implementation(projects.domain.virtualAccount)
implementation(projects.domain.card) implementation(projects.domain.card)
implementation(projects.domain.wallets) implementation(projects.domain.wallets)
implementation(projects.domain.legacy) implementation(projects.domain.legacy)
@ -47,6 +48,7 @@ dependencies {
implementation(projects.domain.quotes) implementation(projects.domain.quotes)
implementation(projects.domain.common) implementation(projects.domain.common)
implementation(projects.features.swap.domain) implementation(projects.features.swap.domain)
implementation(projects.features.virtualAccounts.details.api)
/** Project - Utils */ /** Project - Utils */

View file

@ -1,17 +1,17 @@
package com.tangem.data.pay package com.tangem.data.pay
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.pay.isTangemPayType
import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency 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.TangemPayEligibilityManager
import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.hot.sdk.model.HotWalletId
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -85,7 +85,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
val wallets = userWalletsListRepository.userWallets.value ?: return emptyList() val wallets = userWalletsListRepository.userWallets.value ?: return emptyList()
val candidates = wallets.filter { wallet -> val candidates = wallets.filter { wallet ->
wallet.isMultiCurrency && !wallet.isLocked && wallet.isCompatible() && wallet.isMultiCurrency && !wallet.isLocked && wallet.isTangemPayCompatible &&
!onboardingRepository.isTangemPayDeactivated(wallet.walletId) !onboardingRepository.isTangemPayDeactivated(wallet.walletId)
} }
@ -98,11 +98,6 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
return candidates 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> { private suspend fun List<UserWallet>.addPaeraCustomersData(): List<UserWalletData> {
if (isEmpty()) return emptyList() if (isEmpty()) return emptyList()
@ -139,7 +134,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
onboardingRepository.checkCustomerEligibility() onboardingRepository.checkCustomerEligibility()
} }
return if (entryPoint == null) { return if (entryPoint == null) {
eligibility.isNotEmpty() eligibility.any { it.isTangemPayType }
} else { } else {
eligibility.any { it == entryPoint.toEligibilityType() } eligibility.any { it == entryPoint.toEligibilityType() }
} }

View file

@ -118,6 +118,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
) )
}, },
error = null, error = null,
virtualAccount = null,
) )
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE, 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.WithdrawalSignatureResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.TangemSdkManager
import javax.inject.Inject 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( override suspend fun getWithdrawalSignature(
userWallet: UserWallet, userWallet: UserWallet,
hash: String, hash: String,

View file

@ -5,6 +5,7 @@ import arrow.core.getOrElse
import arrow.core.raise.Raise import arrow.core.raise.Raise
import arrow.core.raise.either import arrow.core.raise.either
import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toMapKey
import com.tangem.core.error.ext.tangemError import com.tangem.core.error.ext.tangemError
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.visa.VisaUtilities 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.VisaActivationError
import com.tangem.domain.visa.error.VisaCardScanError import com.tangem.domain.visa.error.VisaCardScanError
import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.DataToSign import com.tangem.hot.sdk.model.DataToSign
import com.tangem.hot.sdk.model.DeriveWalletRequest import com.tangem.hot.sdk.model.DeriveWalletRequest
import com.tangem.hot.sdk.model.UnlockHotWallet import com.tangem.hot.sdk.model.UnlockHotWallet
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import javax.inject.Inject import javax.inject.Inject
internal class TangemPayHotSdkManager @Inject constructor( 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( suspend fun getWithdrawalSignature(
hotWallet: UserWallet.Hot, hotWallet: UserWallet.Hot,
hash: String, hash: String,

View file

@ -293,5 +293,20 @@ internal interface TangemPayDataModule {
appCoroutineScope = appCoroutineScope, 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.data.common.network.NetworkFactory
import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.common.wallets.UserWalletsListRepository 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.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.pay.TangemPayCurrencyFactory
import javax.inject.Inject import javax.inject.Inject
@ -23,9 +24,7 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor(
} }
override fun create(userWalletId: UserWalletId): CryptoCurrency.Token { override fun create(userWalletId: UserWalletId): CryptoCurrency.Token {
val userWallet = userWalletsListRepository.requireUserWalletsSync() val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
.firstOrNull { it.walletId == userWalletId }
?: error("User wallet with id $userWalletId not found")
val network = networkFactory.create( val network = networkFactory.create(
blockchain = VisaUtilities.visaBlockchain, blockchain = VisaUtilities.visaBlockchain,
userWallet = userWallet, userWallet = userWallet,
@ -40,4 +39,21 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor(
decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, 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.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.utils.catchOn import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.StatusSource import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account import com.tangem.domain.models.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.kyc.KycStatus import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.*
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.quote.QuoteStatus import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo 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.OrderData
import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayEntryPoint 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.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -66,6 +62,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val closeCardRepository: TangemPayCloseCardRepository, private val closeCardRepository: TangemPayCloseCardRepository,
private val cardDetailsRepository: TangemPayCardDetailsRepository, private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val issueCardRepository: TangemPayIssueCardRepository, private val issueCardRepository: TangemPayIssueCardRepository,
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
) : PaymentAccountStatusFetcher { ) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG) private val logger = TangemLogger.withTag(TAG)
@ -114,6 +111,10 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
logger.i("invoke() end ${params.userWalletId}: isRight=${result.isRight()}") logger.i("invoke() end ${params.userWalletId}: isRight=${result.isRight()}")
} }
override suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
paymentAccountStatusesStore.markVirtualAccountProcessing(userWalletId)
}
private suspend fun proceedHasTangemPayResult( private suspend fun proceedHasTangemPayResult(
account: Account.Payment, account: Account.Payment,
hasTangemPay: Boolean, hasTangemPay: Boolean,
@ -341,7 +342,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
fiatRate: BigDecimal?, fiatRate: BigDecimal?,
): PaymentAccountStatusValue { ): PaymentAccountStatusValue {
val cardsById = cards.associateBy { it.cardId } 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 cardInfo = cardsById[productInstance.cardId] ?: return@mapNotNull null
val cardId = productInstance.cardId val cardId = productInstance.cardId
val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(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. // the previously shown order and append newly seen cards at the end.
val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId)) val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId))
val virtualAccount = resolveVirtualAccountOnramp(userWalletId)
return PaymentAccountStatusValue.Loaded( return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
customerId = customerId, customerId = customerId,
@ -389,6 +392,84 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
availableForWithdrawal = availableForWithdrawal.orZero(), availableForWithdrawal = availableForWithdrawal.orZero(),
), ),
error = null, 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 arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.util.BankCredentialsConverter
import com.tangem.data.pay.util.CustomerInfoConverter import com.tangem.data.pay.util.CustomerInfoConverter
import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest 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.OrderRequest
import com.tangem.datasource.api.pay.models.request.SetTangemPayEnabledRequest 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.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore 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.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.BankCredentials
import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.pay.TangemPayEligibilityType
@ -36,7 +39,7 @@ import javax.inject.Inject
private const val VALID_STATUS = "valid" private const val VALID_STATUS = "valid"
@Suppress("LongParameterList") @Suppress("LongParameterList", "TooManyFunctions")
internal class DefaultOnboardingRepository @Inject constructor( internal class DefaultOnboardingRepository @Inject constructor(
private val analytics: AnalyticsEventHandler, private val analytics: AnalyticsEventHandler,
private val dispatcherProvider: CoroutineDispatcherProvider, 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 { override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean {
return tangemPayStorage.isTangemPayDeactivated(userWalletId) 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 { private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId } return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
?: error("no userWallet found") ?: error("no userWallet found")
@ -170,7 +221,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
sendKycAnalytics(customerInfo.kycStatus) sendKycAnalytics(customerInfo.kycStatus)
// Keep the per-card frozen state up to date for every card. // 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) cardFrozenStateStore.store(key = instance.cardId, value = instance.frozenState)
} }
@ -226,6 +277,16 @@ internal class DefaultOnboardingRepository @Inject constructor(
return tangemPayStorage.getTangemPayEligibility().map(TangemPayEligibilityType::fromString) 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 { override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {
return tangemPayStorage.getHideMainOnboardingBanner(userWalletId) 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.Account
import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching 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) { suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Payment) {
logger.i("store($userWalletId): valueType=${status.value::class.simpleName}") logger.i("store($userWalletId): valueType=${status.value::class.simpleName}")
coroutineScope { 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
import com.tangem.domain.pay.model.CustomerInfo.CardInfo import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance 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.domain.pay.model.CustomerInfo.ProductInstance.Status
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.orZero
@ -56,12 +57,13 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
val name = displayName?.ifEmpty { null } val name = displayName?.ifEmpty { null }
return ProductInstance( return ProductInstance(
id = id, id = id,
cardId = cardId, cardId = cardId.orEmpty(),
frozenState = cardFrozenState, frozenState = cardFrozenState,
status = status, status = status,
displayName = if (name != null) CardDisplayName(name).getOrElse { null } else null, displayName = if (name != null) CardDisplayName(name).getOrElse { null } else null,
actualCardLimit = actualCardLimit?.parseCardLimit(), actualCardLimit = actualCardLimit?.parseCardLimit(),
adminCardLimit = adminCardLimit?.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.CANCELED -> Status.CANCELED
CustomerMeResponse.ProductInstance.Status.UNKNOWN -> Status.UNKNOWN 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.datasource.di.NetworkMoshi
import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -23,7 +24,7 @@ internal class TangemPayErrorConverter @Inject constructor(
if (value.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) return VisaApiError.RefreshTokenExpired if (value.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) return VisaApiError.RefreshTokenExpired
val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching { runCatching {
tangemPayErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode tangemPayErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode
}.map { }.map {
VisaApiError.fromBackendError(it) VisaApiError.fromBackendError(it)
@ -31,6 +32,7 @@ internal class TangemPayErrorConverter @Inject constructor(
VisaApiError.UnknownWithoutCode VisaApiError.UnknownWithoutCode
} }
} else { } else {
TangemLogger.e("Not HttpException. ${value.message}", value)
VisaApiError.UnknownWithoutCode 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.converter.VirtualAccountStatusValueDMConverter
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer
import com.tangem.data.virtualaccount.repository.DefaultVirtualAccountActivationRepository
import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore
import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore 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.VirtualAccountStatusFetcher
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier 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 com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@ -40,6 +48,12 @@ internal interface VirtualAccountDataModule {
@Singleton @Singleton
fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher
@Binds
@Singleton
fun bindVirtualAccountActivationRepository(
impl: DefaultVirtualAccountActivationRepository,
): VirtualAccountActivationRepository
companion object { companion object {
@Provides @Provides
@ -77,5 +91,34 @@ internal interface VirtualAccountDataModule {
keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" }, 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