Updated on 2026-08-14
This commit is contained in:
commit
17197af0f7
361 changed files with 16712 additions and 296 deletions
|
|
@ -19,10 +19,12 @@ generation a component belongs to is essential so you don't mix tokens or pull t
|
|||
|---|---|---|---|---|---|
|
||||
| **DS1** (legacy) | `core/ui/src/main/java/com/tangem/core/ui/components/` | `TangemTheme.colors` | `TangemTheme.typography` | `TangemTheme.dimens` | `TangemThemePreview` |
|
||||
| **DS2** (redesign) | `core/ui/src/main/java/com/tangem/core/ui/ds/` | `TangemTheme.colors2` | `TangemTheme.typography2` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` |
|
||||
| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` |
|
||||
| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | literal `.dp` (no token) | `TangemThemePreviewRedesign` |
|
||||
|
||||
> Mind the numbering mismatch: **folder `ds` is DS2**, **folder `ds2` is DS3**.
|
||||
> The `colors2` / `typography2` tokens are `@Deprecated` (ReplaceWith `colors3` / `typography3`).
|
||||
> **DS3 has no dimension token** — `dimens2` is a DS2 token and must **not** be used in `ds2/`
|
||||
> components. Express dimensions as literal `.dp` values (see rule 2 below).
|
||||
|
||||
- **DS1** — the entire current app is built on it. Do **not** add new components here.
|
||||
- **DS2** — redesign components. A transitional generation; don't write new components in it, only
|
||||
|
|
@ -49,9 +51,11 @@ Pattern rules:
|
|||
|
||||
1. **Package & location.** `com.tangem.core.ui.ds2.<component>`, folder
|
||||
`core/ui/.../ds2/<component>/`. The component name is `Tangem<Name>`.
|
||||
2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`,
|
||||
dimensions — `TangemTheme.dimens2.*`. No `colors` / `colors2` / hardcoded values (literal dp/colors
|
||||
are acceptable only inside `@Preview`, where you add `@Suppress("MagicNumber")`).
|
||||
2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`. No
|
||||
`colors` / `colors2` and no hardcoded colors outside `@Preview`. **Dimensions have no DS3 token** —
|
||||
do **not** use `TangemTheme.dimens2.*` (it is a DS2 token); express dimensions as literal `.dp`
|
||||
values and add `@Suppress("MagicNumber")` to the composable (or a `…Ext.kt` / `…Internal.kt` token
|
||||
holder, as `TangemButtonInternal.kt` and `TangemCheckmark.kt` do).
|
||||
3. **Signature.** `modifier: Modifier = Modifier` is mandatory (defaulting to `Modifier`, placed first
|
||||
among the optional params or right after the required ones). Express variants/sizes via a nested
|
||||
`enum` in `object Tangem<Name>` (like `TangemButton.Variant` / `TangemButton.Size`), not boolean flags.
|
||||
|
|
@ -156,7 +160,8 @@ Page layout guidelines live in
|
|||
|
||||
- [ ] Component created under `core/ui/.../ds2/<component>/`, package `com.tangem.core.ui.ds2.<component>`.
|
||||
- [ ] Named `Tangem<Name>`; first optional parameter is `modifier: Modifier = Modifier`.
|
||||
- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`, `dimens2`. No hardcoded values outside previews.
|
||||
- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`. No hardcoded colors outside previews. Dimensions
|
||||
are literal `.dp` (DS3 has no dimension token — never use `dimens2`), with `@Suppress("MagicNumber")`.
|
||||
- [ ] Variants/sizes expressed as an `enum` inside `object Tangem<Name>` (not a set of boolean flags).
|
||||
- [ ] All public types (enums, statuses, constants) declared inside the `object Tangem<Name>`.
|
||||
- [ ] Convenient overloads provided (simpler `@Composable` overloads and/or `object` extension presets).
|
||||
|
|
|
|||
|
|
@ -155,6 +155,8 @@ dependencies {
|
|||
implementation(projects.domain.onramp)
|
||||
implementation(projects.domain.stories)
|
||||
implementation(projects.domain.stories.models)
|
||||
implementation(projects.domain.marketing)
|
||||
implementation(projects.domain.marketing.models)
|
||||
implementation(projects.domain.networks)
|
||||
implementation(projects.domain.quotes)
|
||||
implementation(projects.domain.notifications)
|
||||
|
|
@ -164,6 +166,7 @@ dependencies {
|
|||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.walletManager.models)
|
||||
implementation(projects.domain.yieldSupply)
|
||||
implementation(projects.domain.promo)
|
||||
implementation(projects.domain.blockaid)
|
||||
implementation(projects.domain.hotWallet)
|
||||
implementation(projects.domain.news)
|
||||
|
|
@ -208,6 +211,7 @@ dependencies {
|
|||
implementation(projects.data.transaction)
|
||||
implementation(projects.data.visa)
|
||||
implementation(projects.data.stories)
|
||||
implementation(projects.data.marketing)
|
||||
implementation(projects.data.onboarding)
|
||||
implementation(projects.data.dynamicAddresses)
|
||||
implementation(projects.data.feedback)
|
||||
|
|
@ -225,6 +229,7 @@ dependencies {
|
|||
implementation(projects.data.swap)
|
||||
implementation(projects.data.walletManager)
|
||||
implementation(projects.data.yieldSupply)
|
||||
implementation(projects.data.promo)
|
||||
implementation(projects.data.hotWallet)
|
||||
implementation(projects.data.news)
|
||||
implementation(projects.data.earn)
|
||||
|
|
@ -235,6 +240,8 @@ dependencies {
|
|||
/** Features */
|
||||
implementation(projects.features.addressBook.api)
|
||||
implementation(projects.features.addressBook.impl)
|
||||
implementation(projects.features.marketing.api)
|
||||
implementation(projects.features.marketing.impl)
|
||||
implementation(projects.features.rating.impl)
|
||||
implementation(projects.features.referral.impl)
|
||||
implementation(projects.features.referral.domain)
|
||||
|
|
|
|||
|
|
@ -313,6 +313,16 @@
|
|||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="onboard-virtual-account"
|
||||
android:scheme="tangem" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="news"
|
||||
android:scheme="tangem" />
|
||||
|
|
@ -337,6 +347,16 @@
|
|||
android:host="yield"
|
||||
android:scheme="tangem" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="campaigns"
|
||||
android:scheme="tangem" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit f983c6defd0b2240eb6f134aefe30f7e93696795
|
||||
Subproject commit 5559381cd92d7d8747ca4531462879915499d405
|
||||
|
|
@ -134,6 +134,23 @@ internal class DefaultTangemPayStorage @Inject constructor(
|
|||
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
|
||||
}
|
||||
|
||||
override suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) {
|
||||
appPreferencesStore.store(
|
||||
key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress),
|
||||
value = vaOrderId,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? {
|
||||
return appPreferencesStore.getSyncOrNull(
|
||||
key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress),
|
||||
).takeIf { !it.isNullOrEmpty() }
|
||||
}
|
||||
|
||||
override suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) {
|
||||
appPreferencesStore.store(PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress), "")
|
||||
}
|
||||
|
||||
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) {
|
||||
appPreferencesStore.store(
|
||||
PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.tangem.sdk.api.TangemSdkManager
|
|||
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
|
||||
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
|
||||
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
|
||||
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
|
||||
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
|
||||
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
||||
import dagger.Module
|
||||
|
|
@ -31,6 +32,7 @@ internal class TangemSdkManagerModule {
|
|||
visaCardScanHandler: VisaCardScanHandler,
|
||||
visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
|
||||
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
|
||||
tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
|
||||
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
|
||||
analyticsErrorHandler: AnalyticsErrorHandler,
|
||||
cardRepository: CardRepository,
|
||||
|
|
@ -44,6 +46,7 @@ internal class TangemSdkManagerModule {
|
|||
visaCardScanHandler = visaCardScanHandler,
|
||||
visaCardActivationTaskFactory = visaCardActivationTaskFactory,
|
||||
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
|
||||
tangemPayVirtualAccountTaskFactory = tangemPayVirtualAccountTaskFactory,
|
||||
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
|
||||
analyticsErrorHandler = analyticsErrorHandler,
|
||||
cardRepository = cardRepository,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
|
|||
import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor
|
||||
import com.tangem.tap.domain.tasks.product.*
|
||||
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
|
||||
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
|
||||
import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask
|
||||
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
|
||||
import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask
|
||||
|
|
@ -72,6 +73,7 @@ internal class DefaultTangemSdkManager(
|
|||
private val visaCardScanHandler: VisaCardScanHandler,
|
||||
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
|
||||
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
|
||||
private val tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
|
||||
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
|
||||
private val analyticsErrorHandler: AnalyticsErrorHandler,
|
||||
private val cardRepository: CardRepository,
|
||||
|
|
@ -531,6 +533,24 @@ internal class DefaultTangemSdkManager(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun tangemPayProduceVirtualAccountData(
|
||||
preflightReadFilter: PreflightReadFilter,
|
||||
): Either<Throwable, VirtualAccountActivationData> {
|
||||
return coroutineScope {
|
||||
val result = runTaskAsyncReturnOnMain(
|
||||
runnable = tangemPayVirtualAccountTaskFactory.create(coroutineScope = this),
|
||||
cardId = null,
|
||||
initialMessage = Message(resources.getStringSafe(R.string.initial_message_tap_header)),
|
||||
preflightReadFilter = preflightReadFilter,
|
||||
)
|
||||
|
||||
return@coroutineScope when (result) {
|
||||
is CompletionResult.Failure<*> -> result.error.left()
|
||||
is CompletionResult.Success<VirtualAccountActivationData> -> result.data.right()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getWithdrawalSignature(
|
||||
hash: String,
|
||||
preflightReadFilter: PreflightReadFilter,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import com.tangem.domain.models.scan.ScanResponse
|
|||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.WithdrawalSignatureResult
|
||||
import com.tangem.domain.visa.model.TangemPayInitialCredentials
|
||||
import com.tangem.domain.visa.model.VirtualAccountActivationData
|
||||
import com.tangem.domain.visa.model.VisaActivationInput
|
||||
import com.tangem.domain.visa.model.VisaDataForApprove
|
||||
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
|
||||
|
|
@ -241,6 +242,12 @@ class MockTangemSdkManager(
|
|||
error("Not implemented")
|
||||
}
|
||||
|
||||
override suspend fun tangemPayProduceVirtualAccountData(
|
||||
preflightReadFilter: PreflightReadFilter,
|
||||
): Either<Throwable, VirtualAccountActivationData> {
|
||||
error("Not implemented")
|
||||
}
|
||||
|
||||
override suspend fun getWithdrawalSignature(
|
||||
hash: String,
|
||||
preflightReadFilter: PreflightReadFilter,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
|
|||
import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod
|
||||
import com.tangem.domain.common.wallets.error.*
|
||||
import com.tangem.domain.hotwallet.repository.HotWalletRepository
|
||||
import com.tangem.domain.models.scan.CardDTO
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.models.wallet.*
|
||||
import com.tangem.domain.wallets.R
|
||||
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
|
||||
|
|
@ -127,6 +129,8 @@ internal class DefaultUserWalletsListRepository(
|
|||
canOverride: Boolean,
|
||||
): Either<SaveWalletError, UserWallet> = either {
|
||||
if (canOverride.not() && userWallets.value?.any { it.walletId == userWallet.walletId } == true) {
|
||||
// the wallet was rebuilt from a fresh scan — reconcile the stored card state before rejecting
|
||||
(userWallet as? UserWallet.Cold)?.let { refreshStoredCardState(scanResponse = it.scanResponse) }
|
||||
raise(SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved))
|
||||
}
|
||||
|
||||
|
|
@ -322,6 +326,8 @@ internal class DefaultUserWalletsListRepository(
|
|||
raise(UnlockWalletError.ScannedCardWalletNotMatched)
|
||||
}
|
||||
|
||||
refreshStoredCardState(scanResponse)
|
||||
|
||||
val encryptionKey = UserWalletEncryptionKey(
|
||||
walletId = userWallet.walletId,
|
||||
encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty),
|
||||
|
|
@ -449,6 +455,49 @@ internal class DefaultUserWalletsListRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the persisted card state of an already saved wallet from a freshly scanned card.
|
||||
*
|
||||
* Heals a stale backup status — e.g. when backup was finalized on another device or the app was
|
||||
* terminated before the post-backup update was persisted. A scan of the same physical card is
|
||||
* the ground truth and is applied as is. A scan of another card of the same wallet refreshes the
|
||||
* state too, except when the stored card is [CardDTO.BackupStatus.CardLinked] — its backup is in
|
||||
* progress, so the status is preserved until the same card is scanned again.
|
||||
*/
|
||||
private suspend fun refreshStoredCardState(scanResponse: ScanResponse) {
|
||||
val walletId = UserWalletIdBuilder.scanResponse(scanResponse).build() ?: return
|
||||
val storedWallet = userWallets.value?.find { it.walletId == walletId } as? UserWallet.Cold ?: return
|
||||
|
||||
val storedCard = storedWallet.scanResponse.card
|
||||
val scannedCard = scanResponse.card
|
||||
|
||||
val isUpToDate = storedCard.backupStatus == scannedCard.backupStatus &&
|
||||
storedCard.isAccessCodeSet == scannedCard.isAccessCodeSet
|
||||
if (isUpToDate) return
|
||||
|
||||
// another card of the wallet must not override the stored card's in-progress backup state
|
||||
val isAnotherCard = storedCard.cardId != scannedCard.cardId
|
||||
val isBackupInProgress = storedCard.backupStatus is CardDTO.BackupStatus.CardLinked
|
||||
if (isAnotherCard && isBackupInProgress) return
|
||||
|
||||
val updatedWallet = storedWallet.copy(
|
||||
scanResponse = storedWallet.scanResponse.copy(
|
||||
card = storedCard.copy(
|
||||
backupStatus = scannedCard.backupStatus,
|
||||
isAccessCodeSet = scannedCard.isAccessCodeSet,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if (savePersistentInformation()) {
|
||||
publicInformationRepository.save(updatedWallet, canOverride = true)
|
||||
}
|
||||
|
||||
updateWallets { wallets ->
|
||||
wallets?.addOrReplace(updatedWallet) { it.walletId == walletId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkForUpgradeAndDeleteHotWalletIfNeeded(
|
||||
newUserWallet: UserWallet,
|
||||
oldUserWallet: UserWallet,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ internal fun RootContent(
|
|||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
wcContent: @Composable (modifier: Modifier) -> Unit,
|
||||
promoContent: @Composable (modifier: Modifier) -> Unit,
|
||||
hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit,
|
||||
rootDetectedWarningContent: @Composable (modifier: Modifier) -> Unit,
|
||||
scanFailsContent: @Composable (modifier: Modifier) -> Unit,
|
||||
|
|
@ -80,6 +81,8 @@ internal fun RootContent(
|
|||
|
||||
wcContent(Modifier.fillMaxSize())
|
||||
|
||||
promoContent(Modifier.fillMaxSize())
|
||||
|
||||
hotAccessCodeContent(Modifier.fillMaxSize())
|
||||
|
||||
rootDetectedWarningContent(Modifier.fillMaxSize())
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
|
|||
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
|
||||
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
|
||||
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
|
||||
import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent
|
||||
import com.tangem.features.walletconnect.components.WcRoutingComponent
|
||||
import com.tangem.hot.sdk.TangemHotSdk
|
||||
import com.tangem.hot.sdk.android.create
|
||||
|
|
@ -83,6 +84,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
|||
private val appRouterConfig: AppRouterConfig,
|
||||
private val uiDependencies: UiDependencies,
|
||||
private val wcRoutingComponentFactory: WcRoutingComponent.Factory,
|
||||
private val campaignsComponentFactory: CampaignsComponent.Factory,
|
||||
private val deeplinkFactory: DeepLinkFactory,
|
||||
private val tangemHotSDKProxy: TangemHotSDKProxy,
|
||||
private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory,
|
||||
|
|
@ -112,6 +114,11 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
|||
.create(child("wcRoutingComponent"), params = Unit)
|
||||
}
|
||||
|
||||
private val campaignsComponent: CampaignsComponent by lazy {
|
||||
campaignsComponentFactory
|
||||
.create(child("swapCashbackCampaign"), params = Unit)
|
||||
}
|
||||
|
||||
private val hotAccessCodeRequestComponent: HotAccessCodeRequestComponent by lazy {
|
||||
hotAccessCodeRequestComponentFactory
|
||||
.create(child("hotAccessCodeRequestComponent"), Unit)
|
||||
|
|
@ -278,6 +285,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
|||
onBack = router::pop,
|
||||
modifier = modifier,
|
||||
wcContent = { wcRoutingComponent.Content(it) },
|
||||
promoContent = { campaignsComponent.Content(it) },
|
||||
hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) },
|
||||
rootDetectedWarningContent = { rootDetectedWarningComponent.Content(it) },
|
||||
scanFailsContent = { scanFailsComponent.Content(it) },
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComp
|
|||
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
|
||||
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.*
|
||||
import com.tangem.features.tokendetails.TokenDetailsComponent
|
||||
import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent
|
||||
import com.tangem.features.wallet.WalletEntryComponent
|
||||
import com.tangem.features.walletconnect.components.WalletConnectEntryComponent
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent
|
||||
|
|
@ -114,6 +115,7 @@ internal class ChildFactory @Inject constructor(
|
|||
private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory,
|
||||
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
|
||||
private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory,
|
||||
private val virtualAccountOnboardingComponentFactory: VirtualAccountOnboardingComponent.Factory,
|
||||
private val kycComponentFactory: KycComponent.Factory,
|
||||
private val surveyComponentFactory: SurveyComponent.Factory,
|
||||
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
|
||||
|
|
@ -703,6 +705,23 @@ internal class ChildFactory @Inject constructor(
|
|||
componentFactory = tangemPayWalletOnboardingComponentFactory,
|
||||
)
|
||||
}
|
||||
is AppRoute.VirtualAccountOnboarding -> {
|
||||
createComponentChild(
|
||||
context = context,
|
||||
params = when (val mode = route.mode) {
|
||||
is AppRoute.VirtualAccountOnboarding.Mode.Deeplink ->
|
||||
VirtualAccountOnboardingComponent.Params.Deeplink(
|
||||
userWalletId = mode.userWalletId,
|
||||
deeplink = mode.deeplink,
|
||||
)
|
||||
is AppRoute.VirtualAccountOnboarding.Mode.FromMain ->
|
||||
VirtualAccountOnboardingComponent.Params.FromMain(userWalletId = mode.userWalletId)
|
||||
is AppRoute.VirtualAccountOnboarding.Mode.FromDetailsScreen ->
|
||||
VirtualAccountOnboardingComponent.Params.FromDetailsScreen(userWalletId = mode.userWalletId)
|
||||
},
|
||||
componentFactory = virtualAccountOnboardingComponentFactory,
|
||||
)
|
||||
}
|
||||
is AppRoute.Kyc -> {
|
||||
createComponentChild(
|
||||
context = context,
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@ import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
|
|||
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
|
||||
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
|
||||
import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler
|
||||
import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler
|
||||
import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler
|
||||
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
|
||||
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
|
||||
import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler
|
||||
import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler
|
||||
import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler
|
||||
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
|
||||
import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
|
||||
|
|
@ -57,6 +59,7 @@ internal class DeepLinkFactory @Inject constructor(
|
|||
private val swapDeepLink: SwapDeepLinkHandler.Factory,
|
||||
private val promoDeepLink: PromoDeeplinkHandler.Factory,
|
||||
private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory,
|
||||
private val onboardVirtualAccountsDeepLink: OnboardVirtualAccountsDeepLinkHandler.Factory,
|
||||
private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory,
|
||||
private val tangemPayMainDeepLink: TangemPayMainDeepLinkHandler.Factory,
|
||||
private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory,
|
||||
|
|
@ -64,6 +67,7 @@ internal class DeepLinkFactory @Inject constructor(
|
|||
private val earnDeepLink: EarnDeepLinkHandler.Factory,
|
||||
private val yieldDeepLink: YieldDeepLinkHandler.Factory,
|
||||
private val surveyDeepLink: SurveyDeepLinkHandler.Factory,
|
||||
private val promoCampaignsDeepLink: CampaignsDeepLinkHandler.Factory,
|
||||
) {
|
||||
private val permittedAppRoute = MutableStateFlow(false)
|
||||
|
||||
|
|
@ -173,11 +177,13 @@ internal class DeepLinkFactory @Inject constructor(
|
|||
DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri)
|
||||
DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams)
|
||||
DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri)
|
||||
DeepLinkRoute.OnboardVirtualAccounts.host -> onboardVirtualAccountsDeepLink.create(deeplinkUri)
|
||||
DeepLinkRoute.News.host -> newsDeepLink.create(queryParams)
|
||||
DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams)
|
||||
DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams)
|
||||
DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams)
|
||||
DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams)
|
||||
DeepLinkRoute.Campaigns.host -> promoCampaignsDeepLink.create(queryParams)
|
||||
else -> {
|
||||
TangemLogger.i(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ internal class MockAwareTangemPayStorage @Inject constructor(
|
|||
override suspend fun clearOrderId(customerWalletAddress: String) =
|
||||
real.clearOrderId(customerWalletAddress)
|
||||
|
||||
override suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) =
|
||||
real.storeVirtualAccountOrderId(customerWalletAddress, vaOrderId)
|
||||
|
||||
override suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? =
|
||||
real.getVirtualAccountOrderId(customerWalletAddress)
|
||||
|
||||
override suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) =
|
||||
real.clearVirtualAccountOrderId(customerWalletAddress)
|
||||
|
||||
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) =
|
||||
real.storeCheckCustomerWalletResult(userWalletId, isPaeraCustomer)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@ package com.tangem.tap.domain.userWalletList.repository
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.CompletionResult
|
||||
import com.tangem.common.core.TangemError
|
||||
import com.tangem.common.test.domain.card.MockScanResponseFactory
|
||||
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.utils.TrackingContextProxy
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
||||
import com.tangem.domain.card.configs.GenericCardConfig
|
||||
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.hotwallet.repository.HotWalletRepository
|
||||
import com.tangem.domain.models.scan.CardDTO
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
||||
|
|
@ -144,4 +149,162 @@ internal class DefaultUserWalletsListRepositoryTest {
|
|||
assertThat(result.isLeft()).isTrue()
|
||||
verify(exactly = 0) { trackingContextProxy.eraseContext() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stale backup status WHEN duplicate save rejected THEN stored card state refreshed`() = runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(staleScanResponse)
|
||||
val freshWallet = MockUserWalletFactory.create(freshScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
|
||||
assertThat(updatedWallet.scanResponse.card.backupStatus)
|
||||
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
|
||||
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
|
||||
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN locked wallet with stale backup status WHEN unlock with scanned card THEN stored card state refreshed`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(staleScanResponse).let { wallet ->
|
||||
wallet.copy(
|
||||
scanResponse = wallet.scanResponse.copy(
|
||||
card = wallet.scanResponse.card.copy(wallets = emptyList()),
|
||||
),
|
||||
)
|
||||
}
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
|
||||
coEvery { sensitiveInformationRepository.getAll(any()) } returns CompletionResult.Success(emptyMap())
|
||||
|
||||
// Act
|
||||
val result = repository.unlock(
|
||||
userWalletId = storedWallet.walletId,
|
||||
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(
|
||||
scanResponse = freshScanResponse,
|
||||
source = AnalyticsParam.ScreensSources.SignIn,
|
||||
),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isRight()).isTrue()
|
||||
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
|
||||
assertThat(updatedWallet.scanResponse.card.backupStatus)
|
||||
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
|
||||
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
|
||||
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active card of backup set scanned WHEN duplicate save rejected THEN stored card state refreshed`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(staleScanResponse)
|
||||
val otherCardScanResponse = freshScanResponse.copy(
|
||||
card = freshScanResponse.card.copy(cardId = "OTHER-CARD"),
|
||||
)
|
||||
val freshWallet = MockUserWalletFactory.create(otherCardScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
|
||||
assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId)
|
||||
assertThat(updatedWallet.scanResponse.card.backupStatus)
|
||||
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
|
||||
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
|
||||
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no backup card of same wallet scanned WHEN duplicate save rejected THEN stored status downgraded`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(freshScanResponse)
|
||||
val newCardScanResponse = staleScanResponse.copy(
|
||||
card = staleScanResponse.card.copy(cardId = "SAME-SEED-NEW-CARD"),
|
||||
)
|
||||
val freshWallet = MockUserWalletFactory.create(newCardScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
|
||||
assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId)
|
||||
assertThat(updatedWallet.scanResponse.card.backupStatus).isEqualTo(CardDTO.BackupStatus.NoBackup)
|
||||
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isFalse()
|
||||
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored card linked status WHEN duplicate save with another card rejected THEN status preserved`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val cardLinkedScanResponse = staleScanResponse.copy(
|
||||
card = staleScanResponse.card.copy(backupStatus = CardDTO.BackupStatus.CardLinked(cardCount = 1)),
|
||||
)
|
||||
val storedWallet = MockUserWalletFactory.create(cardLinkedScanResponse)
|
||||
val otherCardScanResponse = freshScanResponse.copy(
|
||||
card = freshScanResponse.card.copy(cardId = "OTHER-CARD"),
|
||||
)
|
||||
val freshWallet = MockUserWalletFactory.create(otherCardScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(repository.userWallets.value).containsExactly(storedWallet)
|
||||
coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored card state is actual WHEN duplicate save rejected THEN nothing persisted`() = runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(freshScanResponse)
|
||||
val freshWallet = MockUserWalletFactory.create(freshScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(repository.userWallets.value).containsExactly(storedWallet)
|
||||
coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val staleScanResponse = MockScanResponseFactory.create(
|
||||
cardConfig = GenericCardConfig(maxWalletCount = 2),
|
||||
derivedKeys = emptyMap(),
|
||||
).let { scanResponse ->
|
||||
scanResponse.copy(card = scanResponse.card.copy(backupStatus = CardDTO.BackupStatus.NoBackup))
|
||||
}
|
||||
|
||||
val freshScanResponse = staleScanResponse.copy(
|
||||
card = staleScanResponse.card.copy(
|
||||
backupStatus = CardDTO.BackupStatus.Active(cardCount = 1),
|
||||
isAccessCodeSet = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,9 +16,11 @@ import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
|
|||
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
|
||||
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
|
||||
import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler
|
||||
import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler
|
||||
import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler
|
||||
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
|
||||
import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler
|
||||
import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler
|
||||
import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler
|
||||
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
|
||||
import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler
|
||||
|
|
@ -84,6 +86,10 @@ class DeepLinkFactoryTest {
|
|||
every { create(any()) } returns mockk()
|
||||
}
|
||||
|
||||
private val onboardVirtualAccountsDeepLink = mockk<OnboardVirtualAccountsDeepLinkHandler.Factory>(relaxed = true) {
|
||||
every { create(any()) } returns mockk()
|
||||
}
|
||||
|
||||
private val tangemPayMainDeepLink = mockk<TangemPayMainDeepLinkHandler.Factory>(relaxed = true) {
|
||||
every { create(any(), any()) } returns mockk()
|
||||
}
|
||||
|
|
@ -112,6 +118,10 @@ class DeepLinkFactoryTest {
|
|||
every { create(any(), any()) } returns mockk()
|
||||
}
|
||||
|
||||
private val campaignsDeepLinkHandlerFactory = mockk<CampaignsDeepLinkHandler.Factory>(relaxed = true) {
|
||||
every { create(any()) } returns mockk()
|
||||
}
|
||||
|
||||
private val marketsTokenExchangesDeepLinkFactory =
|
||||
mockk<MarketsTokenExchangesDeepLinkHandler.Factory>(relaxed = true) {
|
||||
every { create(any(), any()) } returns mockk()
|
||||
|
|
@ -140,12 +150,14 @@ class DeepLinkFactoryTest {
|
|||
swapDeepLink = swapDeepLinkFactory,
|
||||
promoDeepLink = promoDeepLinkFactory,
|
||||
onboardVisaDeepLink = onboardVisaDeepLink,
|
||||
onboardVirtualAccountsDeepLink = onboardVirtualAccountsDeepLink,
|
||||
tangemPayMainDeepLink = tangemPayMainDeepLink,
|
||||
newsDetailsDeepLink = newsDeeplink,
|
||||
newsDeepLink = newsDeepLinkFactory,
|
||||
earnDeepLink = earnDeepLinkFactory,
|
||||
yieldDeepLink = yieldDeepLinkFactory,
|
||||
surveyDeepLink = surveyDeepLinkFactory,
|
||||
promoCampaignsDeepLink = campaignsDeepLinkHandlerFactory,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
|
|
|||
|
|
@ -509,6 +509,24 @@ sealed class AppRoute(val path: String) : Route {
|
|||
@Serializable
|
||||
data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc")
|
||||
|
||||
@Serializable
|
||||
data class VirtualAccountOnboarding(
|
||||
val mode: Mode,
|
||||
) : AppRoute(path = "/virtual_account_onboarding/$mode") {
|
||||
|
||||
@Serializable
|
||||
sealed class Mode {
|
||||
@Serializable
|
||||
data class Deeplink(val userWalletId: UserWalletId, val deeplink: String) : Mode()
|
||||
|
||||
@Serializable
|
||||
data class FromMain(val userWalletId: UserWalletId) : Mode()
|
||||
|
||||
@Serializable
|
||||
data class FromDetailsScreen(val userWalletId: UserWalletId) : Mode()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Survey(val token: String, val displayId: String? = null) : AppRoute(path = "/survey")
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ sealed class DeepLinkRoute {
|
|||
override val host: String = "onboard-visa"
|
||||
}
|
||||
|
||||
data object OnboardVirtualAccounts : DeepLinkRoute() {
|
||||
override val host: String = "onboard-virtual-account"
|
||||
}
|
||||
|
||||
data object PayApp : DeepLinkRoute() {
|
||||
override val host: String = "tangem.com"
|
||||
}
|
||||
|
|
@ -91,6 +95,10 @@ sealed class DeepLinkRoute {
|
|||
data object Survey : DeepLinkRoute() {
|
||||
override val host: String = "survey"
|
||||
}
|
||||
|
||||
data object Campaigns : DeepLinkRoute() {
|
||||
override val host: String = "campaigns"
|
||||
}
|
||||
}
|
||||
|
||||
enum class DeepLinkScheme(val scheme: String) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ object DeeplinkConst {
|
|||
const val PROMO_CODE_KEY = "promo_code"
|
||||
const val REF_KEY = "ref"
|
||||
const val CAMPAIGN_KEY = "campaign"
|
||||
const val CAMPAIGN_ID_KEY = "campaignId"
|
||||
const val NAME_KEY = "name"
|
||||
const val ORDER_KEY = "order"
|
||||
const val INTERVAL_KEY = "interval"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -170,5 +170,17 @@
|
|||
{
|
||||
"name": "AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED",
|
||||
"version": "6.0"
|
||||
},
|
||||
{
|
||||
"name": "TWI_1638_VA_MVP0_ENABLED",
|
||||
"version": "6.0.1"
|
||||
},
|
||||
{
|
||||
"name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED",
|
||||
"version": "6.0.1"
|
||||
},
|
||||
{
|
||||
"name": "TWI_1522_MARKETING_BANNERS_ENABLED",
|
||||
"version": "6.0.1"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -23,6 +23,13 @@ interface TangemPayApi {
|
|||
@GET("v1/customer/me")
|
||||
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
|
||||
|
||||
/** Fiat bank requisites for the Virtual Account on-ramp (VA MVP0, TWI-1638). */
|
||||
@GET("v1/account/bank-credentials/{product_instance_id}")
|
||||
suspend fun getBankCredentials(
|
||||
@Header("Authorization") authHeader: String,
|
||||
@Path("product_instance_id") productInstanceId: String,
|
||||
): ApiResponse<BankCredentialsResponse>
|
||||
|
||||
@GET("v1/customer/wallets/{customer_wallet_id}")
|
||||
suspend fun checkCustomerWalletId(
|
||||
@Path("customer_wallet_id") customerWalletId: String,
|
||||
|
|
@ -40,6 +47,12 @@ interface TangemPayApi {
|
|||
@GET("v1/eligibility/channels")
|
||||
suspend fun getEligibilityChannels(): ApiResponse<TangemPayEligibilityChannels>
|
||||
|
||||
/** Eligibility channels fetched with the user (customer-wallet) token (VA MVP0, TWI-1638). */
|
||||
@GET("v1/eligibility/channels")
|
||||
suspend fun getUserEligibilityChannels(
|
||||
@Header("Authorization") authHeader: String,
|
||||
): ApiResponse<TangemPayEligibilityChannels>
|
||||
|
||||
@GET("v1/order/{order_id}")
|
||||
suspend fun getOrder(
|
||||
@Header("Authorization") authHeader: String,
|
||||
|
|
@ -64,6 +77,13 @@ interface TangemPayApi {
|
|||
@Body body: OrderRequest,
|
||||
): ApiResponse<OrderResponse>
|
||||
|
||||
// TODO: Doston: [REDACTED_TASK_KEY] Unify with method above
|
||||
@POST("v1/order")
|
||||
suspend fun createVirtualAccountOrder(
|
||||
@Header("Authorization") authHeader: String,
|
||||
@Body body: VirtualAccountOrderRequest,
|
||||
): ApiResponse<OrderResponse>
|
||||
|
||||
/** Customer offers — used to gate the issue-additional-card flow. */
|
||||
@GET("v1/customer/offers")
|
||||
suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse<CustomerOffersResponse>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ data class CustomerMeResponse(
|
|||
data class ProductInstance(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "cid") val cid: String?,
|
||||
@Json(name = "card_id") val cardId: String,
|
||||
@Json(name = "card_id") val cardId: String?,
|
||||
@Json(name = "card_wallet_address") val cardWalletAddress: String?,
|
||||
@Json(name = "status") val status: Status,
|
||||
@Json(name = "updated_at") val updatedAt: String,
|
||||
|
|
@ -35,7 +35,17 @@ data class CustomerMeResponse(
|
|||
@Json(name = "display_name") val displayName: String?,
|
||||
@Json(name = "actual_card_limit") val actualCardLimit: CardLimit?,
|
||||
@Json(name = "admin_card_limit") val adminCardLimit: CardLimit?,
|
||||
@Json(name = "product_specification_data_type") val specificationDataType: SpecificationDataType,
|
||||
) {
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class SpecificationDataType {
|
||||
@Json(name = "ACCOUNT")
|
||||
ACCOUNT,
|
||||
|
||||
@Json(name = "CARD")
|
||||
CARD,
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class Status {
|
||||
@Json(name = "NEW")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -30,10 +30,12 @@ data class PromotionsResponse(
|
|||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PromoToken(
|
||||
@Json(name = "tokenId") val tokenId: String,
|
||||
@Json(name = "tokenAddress") val tokenAddress: String,
|
||||
@Json(name = "tokenSymbol") val tokenSymbol: String,
|
||||
@Json(name = "tokenName") val tokenName: String,
|
||||
@Json(name = "networkId") val networkId: String,
|
||||
@Json(name = "decimals") val decimals: Int,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package com.tangem.datasource.api.tangemTech
|
||||
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
|
||||
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
|
||||
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse
|
||||
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
|
||||
import com.tangem.datasource.api.stories.models.StoryContentResponse
|
||||
|
|
@ -120,7 +123,7 @@ interface TangemTechApi {
|
|||
@GET("v1/stories/{story_id}")
|
||||
suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse>
|
||||
|
||||
// region yield-boost promo
|
||||
// region promotions
|
||||
@GET("/v2/promotion")
|
||||
suspend fun getPromotions(
|
||||
@Query("walletId") walletId: String,
|
||||
|
|
@ -130,6 +133,11 @@ interface TangemTechApi {
|
|||
@Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite")
|
||||
@GET("/v2/promotion/yield-apr-boost/status")
|
||||
suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse<YieldBoostStatusResponse>
|
||||
|
||||
@POST("/v2/promotion/registrations")
|
||||
suspend fun createPromotionRegistration(
|
||||
@Body body: CreatePromotionRegistrationBody,
|
||||
): ApiResponse<PromotionRegistrationResponse>
|
||||
// endregion
|
||||
|
||||
// region push notifications
|
||||
|
|
@ -235,4 +243,18 @@ interface TangemTechApi {
|
|||
@GET("v1/earn/networks")
|
||||
suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse<EarnNetworkListResponse>
|
||||
// endregion
|
||||
|
||||
// region marketing
|
||||
@GET("api/v1/marketing/campaigns")
|
||||
suspend fun getMarketingCampaigns(
|
||||
@Query("type") type: String,
|
||||
@Query("language") language: String? = null,
|
||||
@Query("fromNetwork") fromNetwork: String? = null,
|
||||
@Query("fromContractAddress") fromContractAddress: String? = null,
|
||||
@Query("toNetwork") toNetwork: String? = null,
|
||||
@Query("toContractAddress") toContractAddress: String? = null,
|
||||
@Query("fromFiat") fromFiat: String? = null,
|
||||
@Header("If-None-Match") eTag: String? = null,
|
||||
): ApiResponse<MarketingCampaignsResponse>
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -101,6 +101,10 @@ internal object GeneratedEnvironmentConfigConverter {
|
|||
apiKey = GeneratedEnvironmentConfig.quiknodeMonadApiKey,
|
||||
subdomain = GeneratedEnvironmentConfig.quiknodeMonadSubdomain,
|
||||
),
|
||||
quickNodeHederaCredentials = QuickNodeCredentials(
|
||||
apiKey = GeneratedEnvironmentConfig.quiknodeHederaApiKey,
|
||||
subdomain = GeneratedEnvironmentConfig.quiknodeHederaSubdomain,
|
||||
),
|
||||
infuraProjectId = GeneratedEnvironmentConfig.infuraProjectId,
|
||||
tronGridApiKey = GeneratedEnvironmentConfig.tronGridApiKey,
|
||||
nowNodeCredentials = NowNodeCredentials(apiKey = GeneratedEnvironmentConfig.nowNodesApiKey),
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ object PreferencesKeys {
|
|||
val TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY by lazy {
|
||||
stringPreferencesKey(name = "tangemPayActiveWithdrawOrdersKey")
|
||||
}
|
||||
val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityList") }
|
||||
val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityListV2") }
|
||||
|
||||
fun getShouldShowNotificationKey(key: String) = booleanPreferencesKey("showShowNotificationUM_$key")
|
||||
// endregion
|
||||
|
|
@ -189,6 +189,9 @@ object PreferencesKeys {
|
|||
fun getTangemPayOrderIdKey(customerWalletAddress: String) =
|
||||
stringPreferencesKey("tangem_pay_order_id_key_$customerWalletAddress")
|
||||
|
||||
fun getTangemPayVirtualAccountOrderIdKey(customerWalletAddress: String) =
|
||||
stringPreferencesKey("tangem_pay_va_order_id_key_$customerWalletAddress")
|
||||
|
||||
fun getTangemPayCustomerWalletAddressKey(userWalletId: UserWalletId) =
|
||||
stringPreferencesKey("tangem_pay_customer_wallet_address_key_${userWalletId.stringValue}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -23,6 +23,12 @@ interface TangemPayStorage {
|
|||
|
||||
suspend fun clearOrderId(customerWalletAddress: String)
|
||||
|
||||
suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String)
|
||||
|
||||
suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String?
|
||||
|
||||
suspend fun clearVirtualAccountOrderId(customerWalletAddress: String)
|
||||
|
||||
suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean
|
||||
|
||||
suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -441,6 +441,7 @@
|
|||
<string name="common_rename">Umbenennen</string>
|
||||
<string name="common_required">Erforderlich</string>
|
||||
<string name="common_reset">Zurücksetzen</string>
|
||||
<string name="common_retry">Wiederholen</string>
|
||||
<string name="common_save">Speichern</string>
|
||||
<string name="common_save_changes">Änderungen speichern</string>
|
||||
<string name="common_search">Suchen</string>
|
||||
|
|
@ -907,9 +908,11 @@
|
|||
<item quantity="one">Vermögenswert</item>
|
||||
<item quantity="other">Vermögenswerte</item>
|
||||
</plurals>
|
||||
<string name="market_chart_bubble_no_amount">Kein Betrag
auf Token</string>
|
||||
<string name="market_chart_bubble_no_data">Keine Daten</string>
|
||||
<string name="market_chart_bubble_total_value">Gesamtwert</string>
|
||||
<string name="market_chart_can_not_load_data">Daten konnten nicht geladen werden</string>
|
||||
<string name="market_chart_no_amount">Du hast keine Token mit diesem Betrag.</string>
|
||||
<string name="market_chart_top_holding">Top-Halterung %s</string>
|
||||
<string name="markets_about_coin_header">Über diesen Coin</string>
|
||||
<string name="markets_add_to_my_portfolio_description">Um dieses Asset zu kaufen, zu tauschen oder zu erhalten, füge diesen Deinem Portfolio hinzu</string>
|
||||
|
|
@ -1863,6 +1866,9 @@
|
|||
<string name="tangempay_card_details_add_funds">Guthaben hinzufügen</string>
|
||||
<string name="tangempay_card_details_add_funds_subtitle">Aufladeoptionen</string>
|
||||
<string name="tangempay_card_details_add_to_wallet_button_text">Zu Google Wallet hinzufügen</string>
|
||||
<string name="tangempay_card_details_awaiting_deposit_cancel_button">Stornieren %1$s, umziehen nach %2$s</string>
|
||||
<string name="tangempay_card_details_awaiting_deposit_subtitle">Um die monatliche Gebühr für den Tarif zu bezahlen und die Karte zu nutzen</string>
|
||||
<string name="tangempay_card_details_awaiting_deposit_title">Laden Sie Ihr Konto auf unter %1$s</string>
|
||||
<string name="tangempay_card_details_card_number">Kartennummer</string>
|
||||
<string name="tangempay_card_details_change_pin">PIN-Code</string>
|
||||
<string name="tangempay_card_details_change_pin_success_description">Die Karte ist vollständig für Zahlungen bereit.</string>
|
||||
|
|
@ -1897,6 +1903,8 @@
|
|||
<string name="tangempay_card_details_rename_card_placeholder">Kartenname</string>
|
||||
<string name="tangempay_card_details_reveal_text">Aufdecken</string>
|
||||
<string name="tangempay_card_details_show_details">Kartendetails</string>
|
||||
<string name="tangempay_card_details_system_downgrade_subtitle">Sollte der Kontostand unter null bleiben, werden Ihre „ %1$s “-Karten am %2$s</string>
|
||||
<string name="tangempay_card_details_system_downgrade_title">Laden Sie Ihr Konto in Kürze auf.</string>
|
||||
<string name="tangempay_card_details_title">Kartendetails</string>
|
||||
<string name="tangempay_card_details_unable_to_rename_card_description">Bitte versuche es später noch einmal.</string>
|
||||
<string name="tangempay_card_details_unfreeze_card">Karte entsperren</string>
|
||||
|
|
@ -1924,17 +1932,40 @@
|
|||
<item quantity="one">%d Karte</item>
|
||||
<item quantity="other">%d Karten</item>
|
||||
</plurals>
|
||||
<string name="tangempay_cashback_accruals_calc_description">Wir bearbeiten Käufe innerhalb von 5 Tagen nach der Transaktion und berücksichtigen nur abgeschlossene Transaktionen.</string>
|
||||
<string name="tangempay_cashback_accruals_calc_title">Wie berechnen wir Cashback?</string>
|
||||
<string name="tangempay_cashback_accruals_exceptions_description">Für Einkäufe vor Ort bei Händlern in der EU wird kein Cashback gewährt; dies gilt ebenfalls für Abhebungen, Überweisungen, bargeldähnliche Zahlungen, Mobilfunkrechnungen, behördliche Dienstleistungen und bestimmte andere Kategorien.</string>
|
||||
<string name="tangempay_cashback_accruals_exceptions_title">Ausnahmen</string>
|
||||
<string name="tangempay_cashback_accruals_pay_description">Vom 2. bis zum 5. des nächsten Monats</string>
|
||||
<string name="tangempay_cashback_accruals_pay_title">Wie erfolgt die Auszahlung von Cashback?</string>
|
||||
<string name="tangempay_cashback_accruals_subtitle">Grenzen und Ausnahmen</string>
|
||||
<string name="tangempay_cashback_accruals_title">Abgrenzungen</string>
|
||||
<string name="tangempay_cashback_additional_permanent">Dauerhaft</string>
|
||||
<string name="tangempay_cashback_additional_title">Zusätzliches Cashback</string>
|
||||
<string name="tangempay_cashback_additional_until">Bis %1$s</string>
|
||||
<string name="tangempay_cashback_deactivated_description">Dies geschah aufgrund Ihres verdächtigen Verhaltens. Wenden Sie sich an den Support, um mehr zu erfahren.</string>
|
||||
<string name="tangempay_cashback_deactivated_title">Cashback deaktiviert</string>
|
||||
<string name="tangempay_cashback_deposited_on">Wird eingezahlt am %1$s</string>
|
||||
<string name="tangempay_cashback_details_cap">%1$s maximal pro Monat</string>
|
||||
<string name="tangempay_cashback_details_eu_excluded">Kein Cashback für Einkäufe vor Ort bei Händlern in der EU</string>
|
||||
<string name="tangempay_cashback_details_paid_in">Bezahlt in %1$s</string>
|
||||
<string name="tangempay_cashback_details_tier">%1$s%% Bei allen Einkäufen mit Ihren „ %2$s “-Karten gilt ein Mindestumsatz von %3$s</string>
|
||||
<string name="tangempay_cashback_error_title">Die Seite konnte nicht geladen werden.\nZum Neuladen bitte antippen</string>
|
||||
<string name="tangempay_cashback_refund_banner">Wir haben eine Rückerstattung für einen Kauf erhalten, für den zuvor bereits Cashback gewährt worden war</string>
|
||||
<string name="tangempay_cashback_total_earned">%1$s insgesamt verdient</string>
|
||||
<string name="tangempay_cashback_widget_title">%1$s Cashback in %2$s</string>
|
||||
<string name="tangempay_change_pin_code">PIN-Code ändern</string>
|
||||
<string name="tangempay_come_back_if_forget_pin">Kehren Sie zur App zurück, falls Sie ihn vergessen.</string>
|
||||
<string name="tangempay_common_card">Karte</string>
|
||||
<string name="tangempay_common_error_loading">Fehler beim Laden</string>
|
||||
<string name="tangempay_current_plan_active_till_notification">Ihr „ %1$s “-Tarif ist bis zum %2$sgültig; danach werden wir Sie auf %3$sumstellen. Für %4$s fallen keine Kosten an.</string>
|
||||
<string name="tangempay_current_plan_change">Tarif wechseln</string>
|
||||
<string name="tangempay_current_plan_fee_charged_notification">%1$s Die monatliche Gebühr wird am %2$s</string>
|
||||
<string name="tangempay_current_plan_section_card">Kartenbezogen</string>
|
||||
<string name="tangempay_current_plan_section_plan">Planbezogen</string>
|
||||
<string name="tangempay_current_plan_stay_button">Bleib dran %1$s</string>
|
||||
<string name="tangempay_current_plan_stay_sheet_body">Ihr Übergang auf „ %1$s “ wird storniert.</string>
|
||||
<string name="tangempay_current_plan_stay_sheet_title">Möchtest du auf „ %1$s“ bleiben?</string>
|
||||
<string name="tangempay_current_plan_title">Aktueller Plan</string>
|
||||
<string name="tangempay_daily_limit_hint" formatted="false">Limit von %s bis %s festlegen</string>
|
||||
<string name="tangempay_daily_limit_set_button">Limits festlegen</string>
|
||||
|
|
@ -2046,7 +2077,16 @@
|
|||
<string name="tangempay_select_plan_btn_select">Auswählen</string>
|
||||
<string name="tangempay_select_plan_btn_upgrade">Tarif wechseln</string>
|
||||
<string name="tangempay_select_plan_compare">Tarife vergleichen</string>
|
||||
<string name="tangempay_select_plan_confirm_downgrade_title">Ihr „ %1$s “-Tarif und Ihre „ %2$s “-Karten sind gültig bis %3$s</string>
|
||||
<string name="tangempay_select_plan_confirm_point_cancel_till">Sie können diesen Übergang bis zum %1$s</string>
|
||||
<string name="tangempay_select_plan_confirm_point_cards_closed">Dein %1$s Die Karten werden geschlossen</string>
|
||||
<string name="tangempay_select_plan_confirm_point_monthly_fee">%1$s Die monatliche Gebühr wird von Ihrem Konto abgebucht.</string>
|
||||
<string name="tangempay_select_plan_confirm_point_move_on_date">Am %1$s werden wir Sie auf den Tarif „ %2$s “ umstellen.</string>
|
||||
<string name="tangempay_select_plan_confirm_point_no_fee">Es fällt keine Gebühr an</string>
|
||||
<string name="tangempay_select_plan_confirm_point_virtual_card">In wenigen Minuten erhalten Sie Ihre virtuelle „ %1$s “.</string>
|
||||
<string name="tangempay_select_plan_confirm_switch_title">Sie wechseln zu %1$s</string>
|
||||
<string name="tangempay_select_plan_confirm_title">Auswahl bestätigen</string>
|
||||
<string name="tangempay_select_plan_confirm_upgrade_title">Wir stellen für Sie eine „ %1$s “ aus.</string>
|
||||
<string name="tangempay_select_plan_title">Plan auswählen</string>
|
||||
<string name="tangempay_service_unavailable_description">Wir beheben ein technisches Problem. Bitte versuchen Sie es später erneut.</string>
|
||||
<string name="tangempay_service_unavailable_title">Service vorübergehend nicht verfügbar</string>
|
||||
|
|
|
|||
|
|
@ -438,6 +438,7 @@
|
|||
<string name="common_rename">Renombrar</string>
|
||||
<string name="common_required">Requerido</string>
|
||||
<string name="common_reset">Resetear</string>
|
||||
<string name="common_retry">Reintentar</string>
|
||||
<string name="common_save">Guarde</string>
|
||||
<string name="common_save_changes">Guardar cambios</string>
|
||||
<string name="common_search">Buscar</string>
|
||||
|
|
|
|||
|
|
@ -416,6 +416,7 @@
|
|||
<string name="common_rename">Renommer</string>
|
||||
<string name="common_required">Obligatoire</string>
|
||||
<string name="common_reset">Réinitialiser</string>
|
||||
<string name="common_retry">Réessayer</string>
|
||||
<string name="common_save">Enregistrez</string>
|
||||
<string name="common_save_changes">Sauvegarder les modifications</string>
|
||||
<string name="common_search">Rechercher</string>
|
||||
|
|
|
|||
|
|
@ -427,6 +427,7 @@
|
|||
<string name="common_rename">名前を変更</string>
|
||||
<string name="common_required">必須</string>
|
||||
<string name="common_reset">リセット</string>
|
||||
<string name="common_retry">リトライ</string>
|
||||
<string name="common_save">保存</string>
|
||||
<string name="common_save_changes">変更内容を保存</string>
|
||||
<string name="common_search">検索</string>
|
||||
|
|
|
|||
|
|
@ -441,6 +441,7 @@
|
|||
<string name="common_rename">Renomear</string>
|
||||
<string name="common_required">Obrigatório</string>
|
||||
<string name="common_reset">Reiniciar</string>
|
||||
<string name="common_retry">Tentar novamente</string>
|
||||
<string name="common_save">Salvar</string>
|
||||
<string name="common_save_changes">Salvar alterações</string>
|
||||
<string name="common_search">Procurar</string>
|
||||
|
|
@ -1732,7 +1733,7 @@
|
|||
<string name="support_chat_screen_title">Chat de suporte</string>
|
||||
<string name="support_chat_share_logs_button">Anexar logs do aplicativo</string>
|
||||
<string name="support_chat_swap_prefilled_message">Dados da operação SWAP:\nDe: %1$s %2$s\nPara: %3$s %4$s\nPor %5$s - %6$s</string>
|
||||
<string name="support_selector_view_chat_button">Abra o e-mail</string>
|
||||
<string name="support_selector_view_chat_button">Abra o chat</string>
|
||||
<string name="support_selector_view_email_button">Abra o e-mail</string>
|
||||
<string name="swap_approve_description">Ao aprovar, você permite que o contrato inteligente utilize seus tokens em transações futuras.</string>
|
||||
<string name="swap_detailed_mode">Modo detalhado</string>
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@
|
|||
<string name="address_book_no_contacts">Нет добавленных контактов</string>
|
||||
<string name="address_book_no_contacts_description">Здесь отобразятся добавленные вами контакты.</string>
|
||||
<string name="address_book_remove_address">Удалить адрес</string>
|
||||
<string name="address_book_save_address">Сохранить адрес</string>
|
||||
<string name="address_book_save_contact">Сохранить контакт</string>
|
||||
<string name="address_book_save_to_wallet_title">Сохранить в кошелек</string>
|
||||
<string name="address_book_save_wallet_to_description">Этот контакт будет привязан к этому кошельку в адресной книге.</string>
|
||||
|
|
@ -458,6 +459,7 @@
|
|||
<string name="common_rename">Переименовать</string>
|
||||
<string name="common_required">Требуется</string>
|
||||
<string name="common_reset">Сброс</string>
|
||||
<string name="common_retry">Повторить</string>
|
||||
<string name="common_save">Сохранить</string>
|
||||
<string name="common_save_changes">Сохранить изменения</string>
|
||||
<string name="common_search">Поиск</string>
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@
|
|||
<string name="address_book_no_contacts">Немає доданих контактів</string>
|
||||
<string name="address_book_no_contacts_description">Тут відображатимуться додані вами контакти.</string>
|
||||
<string name="address_book_remove_address">Видалити адресу</string>
|
||||
<string name="address_book_save_address">Зберегти адресу</string>
|
||||
<string name="address_book_save_contact">Зберегти контакт</string>
|
||||
<string name="address_book_save_to_wallet_title">Зберегти в гаманець</string>
|
||||
<string name="address_book_save_wallet_to_description">Цей контакт буде прив\'язано до цього гаманця в адресній книзі.</string>
|
||||
|
|
@ -458,6 +459,7 @@
|
|||
<string name="common_rename">Перейменувати</string>
|
||||
<string name="common_required">Обов\'язково</string>
|
||||
<string name="common_reset">Скинути</string>
|
||||
<string name="common_retry">Повторити</string>
|
||||
<string name="common_save">Зберегти</string>
|
||||
<string name="common_save_changes">Зберегти зміни</string>
|
||||
<string name="common_search">Пошук</string>
|
||||
|
|
|
|||
|
|
@ -426,6 +426,7 @@
|
|||
<string name="common_rename">重命名</string>
|
||||
<string name="common_required">必需的</string>
|
||||
<string name="common_reset">重置</string>
|
||||
<string name="common_retry">重试</string>
|
||||
<string name="common_save">节省</string>
|
||||
<string name="common_save_changes">保存更改</string>
|
||||
<string name="common_search">搜索</string>
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
<string name="common_range">%1$s-%2$s</string>
|
||||
<string name="common_reject">拒絕</string>
|
||||
<string name="common_rename">重新命名</string>
|
||||
<string name="common_retry">重試</string>
|
||||
<string name="common_save_changes">保存設置</string>
|
||||
<string name="common_search">搜索</string>
|
||||
<string name="common_search_tokens">搜尋代幣</string>
|
||||
|
|
|
|||
|
|
@ -441,6 +441,7 @@
|
|||
<string name="common_rename">Rename</string>
|
||||
<string name="common_required">Required</string>
|
||||
<string name="common_reset">Reset</string>
|
||||
<string name="common_retry">Retry</string>
|
||||
<string name="common_save">Save</string>
|
||||
<string name="common_save_changes">Save changes</string>
|
||||
<string name="common_search">Search</string>
|
||||
|
|
@ -741,6 +742,7 @@
|
|||
<string name="force_update_button">Update</string>
|
||||
<string name="force_update_os_description">Your operating system is out of date. Please update it to continue using the app.</string>
|
||||
<string name="force_update_os_title">Update Your OS</string>
|
||||
<string name="force_update_required_action">Update app</string>
|
||||
<string name="force_update_warning_message">Please update the app to its latest version to ensure proper functionality.</string>
|
||||
<string name="force_update_warning_title">Update required</string>
|
||||
<string name="gasless_not_enough_funds_to_cover_token_fee">Not enough funds</string>
|
||||
|
|
@ -908,9 +910,11 @@
|
|||
<item quantity="one">%d asset</item>
|
||||
<item quantity="other">%d assets</item>
|
||||
</plurals>
|
||||
<string name="market_chart_bubble_no_amount">No amount
on tokens</string>
|
||||
<string name="market_chart_bubble_no_data">No data</string>
|
||||
<string name="market_chart_bubble_total_value">Total value</string>
|
||||
<string name="market_chart_can_not_load_data">Can’t load data</string>
|
||||
<string name="market_chart_no_amount">You don’t have any tokens with amount</string>
|
||||
<string name="market_chart_top_holding">Top holding %s</string>
|
||||
<string name="markets_about_coin_header">About coin</string>
|
||||
<string name="markets_add_to_my_portfolio_description">To buy, exchange, or receive this asset, add it to your portfolio</string>
|
||||
|
|
@ -1930,9 +1934,35 @@
|
|||
<item quantity="one">%d card</item>
|
||||
<item quantity="other">%d cards</item>
|
||||
</plurals>
|
||||
<string name="tangempay_cashback_accruals_calc_description">We process purchases within 5 days after the operation and count only completed transactions</string>
|
||||
<string name="tangempay_cashback_accruals_calc_title">How we calculate cashback?</string>
|
||||
<string name="tangempay_cashback_accruals_exceptions_description">No cashback will be awarded for in-person/in-store purchases at EU merchants; also for withdrawals, transfers, quasi-cash, mobile phone bills, government services and certain other categories</string>
|
||||
<string name="tangempay_cashback_accruals_exceptions_title">Exceptions</string>
|
||||
<string name="tangempay_cashback_accruals_pay_description">From the 2nd and the 5th of the next month</string>
|
||||
<string name="tangempay_cashback_accruals_pay_title">How we pay cashback?</string>
|
||||
<string name="tangempay_cashback_accruals_subtitle">Limits and exceptions</string>
|
||||
<string name="tangempay_cashback_accruals_title">Accruals</string>
|
||||
<string name="tangempay_cashback_additional_permanent">Permanent</string>
|
||||
<string name="tangempay_cashback_additional_title">Additional cashback</string>
|
||||
<string name="tangempay_cashback_additional_until">Until %1$s</string>
|
||||
<string name="tangempay_cashback_deactivated_description">It was made due to your suspicious behavior. Contact support to learn more</string>
|
||||
<string name="tangempay_cashback_deactivated_title">Cashback deactivated</string>
|
||||
<string name="tangempay_cashback_deposit_banner">Cashback %1$s for %2$s will be deposited till %3$s</string>
|
||||
<string name="tangempay_cashback_deposited_on">Will be deposited on %1$s</string>
|
||||
<string name="tangempay_cashback_details_cap">%1$s max per month</string>
|
||||
<string name="tangempay_cashback_details_eu_excluded">No cashback for in-person purchases at EU merchants</string>
|
||||
<string name="tangempay_cashback_details_paid_in">Paid in %1$s</string>
|
||||
<string name="tangempay_cashback_details_tier">%1$s%% for all purchases with your %2$s cards, min purchase %3$s</string>
|
||||
<string name="tangempay_cashback_earned_title">%1$s earned in %2$s</string>
|
||||
<string name="tangempay_cashback_empty_subtitle">Collected amount will be shown here</string>
|
||||
<string name="tangempay_cashback_empty_title">Start spending\nand earn cashback</string>
|
||||
<string name="tangempay_cashback_error_title">Failed to load page.\nTap to reload</string>
|
||||
<string name="tangempay_cashback_rate_subtitle">With your %1$s plan</string>
|
||||
<string name="tangempay_cashback_rate_title">Cashback %1$s%%</string>
|
||||
<string name="tangempay_cashback_rate_title_up_to">Cashback up to %1$s%%</string>
|
||||
<string name="tangempay_cashback_refund_banner">We received a refund for a purchase for which cashback had previously been awarded</string>
|
||||
<string name="tangempay_cashback_title">Cashback</string>
|
||||
<string name="tangempay_cashback_total_earned">%1$s earned in total</string>
|
||||
<string name="tangempay_cashback_widget_title">%1$s cashback in %2$s</string>
|
||||
<string name="tangempay_change_pin_code">Change PIN-code</string>
|
||||
<string name="tangempay_come_back_if_forget_pin">Come back to the app if you forget it.</string>
|
||||
|
|
@ -2091,8 +2121,11 @@
|
|||
<string name="tangempay_topup_swap_body">Use crypto from your wallet to top up your payment account</string>
|
||||
<string name="tangempay_topup_swap_title">From your Tangem Wallet</string>
|
||||
<string name="tangempay_usdc_on_polygon_network">USDC on Polygon network</string>
|
||||
<string name="tangempay_va_account_details">Account details</string>
|
||||
<string name="tangempay_va_available_to_deposit_day">Available to deposit per day:</string>
|
||||
<string name="tangempay_va_banking_details_error_description">Please try again or contact support if the issue persists</string>
|
||||
<string name="tangempay_va_banking_details_error_title">Couldn\'t load banking details</string>
|
||||
<string name="tangempay_va_limit_resetting_everyday">Limit is resetting every day</string>
|
||||
<string name="tangempay_visa_benefits">Visa Benefits</string>
|
||||
<string name="tangempay_withdrawal_note_description">Funds from refunded purchases won’t 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>
|
||||
|
|
|
|||
|
|
@ -323,7 +323,9 @@ fun BoxScope.FooterOverlay(
|
|||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
) {
|
||||
if (gradientHeight > 0.dp) {
|
||||
val isGradientDisplayed = gradientHeight > 0.dp
|
||||
|
||||
if (isGradientDisplayed) {
|
||||
Fade(
|
||||
backgroundColor = fadeMax,
|
||||
height = gradientHeight,
|
||||
|
|
@ -333,7 +335,7 @@ fun BoxScope.FooterOverlay(
|
|||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(measuredFooterHeight ?: 0.dp)
|
||||
.background(fadeMax),
|
||||
.background(if (isGradientDisplayed) fadeMax else Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,11 +49,11 @@ data class MessageBottomSheetUM(
|
|||
var backgroundType: BackgroundType = BackgroundType.Unspecified,
|
||||
) : Element {
|
||||
enum class Type {
|
||||
Unspecified, Accent, Informative, Attention, Warning,
|
||||
Unspecified, Accent, Informative, Attention, Warning, Success,
|
||||
}
|
||||
|
||||
enum class BackgroundType {
|
||||
Unspecified, SameAsTint, Accent, Informative, Attention, Warning,
|
||||
Unspecified, SameAsTint, Accent, Informative, Attention, Warning, Success,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ private fun BottomSheetVector(vector: MessageBottomSheetUM.Vector, modifier: Mod
|
|||
MessageBottomSheetUM.Vector.Type.Informative -> TangemTheme.colors3.icon.status.info
|
||||
MessageBottomSheetUM.Vector.Type.Attention -> TangemTheme.colors3.icon.status.warning
|
||||
MessageBottomSheetUM.Vector.Type.Warning -> TangemTheme.colors3.icon.status.error
|
||||
MessageBottomSheetUM.Vector.Type.Success -> TangemTheme.colors3.icon.status.success
|
||||
}
|
||||
|
||||
val backgroundColor = when (vector.backgroundType) {
|
||||
|
|
@ -223,6 +224,7 @@ private fun BottomSheetVector(vector: MessageBottomSheetUM.Vector, modifier: Mod
|
|||
MessageBottomSheetUM.Vector.BackgroundType.Informative -> TangemTheme.colors3.bg.status.infoSubtle
|
||||
MessageBottomSheetUM.Vector.BackgroundType.Attention -> TangemTheme.colors3.bg.status.warningSubtle
|
||||
MessageBottomSheetUM.Vector.BackgroundType.Warning -> TangemTheme.colors3.bg.status.errorSubtle
|
||||
MessageBottomSheetUM.Vector.BackgroundType.Success -> TangemTheme.colors3.bg.status.successSubtle
|
||||
}
|
||||
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -178,11 +178,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
|
|||
|
||||
val isKeyboardOpen by rememberIsKeyboardVisible()
|
||||
val buttonHeight by animateDpAsState(
|
||||
if (footer != null) {
|
||||
80.dp
|
||||
} else {
|
||||
0.dp
|
||||
},
|
||||
targetValue = if (footer != null) 80.dp else 0.dp,
|
||||
)
|
||||
// Offset calculation for keyboard scroll adjustment:
|
||||
// 1) Button height (footer)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -143,7 +143,8 @@ fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefe
|
|||
val formattedAmount = formatter.format(value)
|
||||
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
|
||||
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
|
||||
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it).takeIf { i -> i >= 0 } }
|
||||
?: formattedAmount.length
|
||||
|
||||
combinedReference(
|
||||
stringReference(formattedAmount.take(separatorIndex)),
|
||||
|
|
@ -165,8 +166,9 @@ fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefe
|
|||
cryptoCurrencySymbol = symbol,
|
||||
)
|
||||
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
|
||||
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
|
||||
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it).takeIf { i -> i >= 0 } }
|
||||
?: formattedAmount.length
|
||||
|
||||
combinedReference(
|
||||
stringReference(formattedAmount.take(separatorIndex)),
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ fun BigDecimalFiatFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefere
|
|||
value.zeroIfRoundsToZero(FIAT_MARKET_DEFAULT_DIGITS)
|
||||
}
|
||||
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
|
||||
val currencySymbol = formatterCurrency.getSymbol(locale)
|
||||
val rawFormatted = formatter.format(formattingAmount)
|
||||
|
||||
|
|
@ -200,7 +200,7 @@ private fun BigDecimalFiatFormatStyled.price(spanStyleReference: SpanStyleRefere
|
|||
roundingMode = RoundingMode.HALF_UP
|
||||
}
|
||||
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
|
||||
val currencySymbol = formatterCurrency.getSymbol(locale)
|
||||
val rawFormatted = formatter.format(priceAmount)
|
||||
|
||||
|
|
|
|||
|
|
@ -190,11 +190,13 @@ object TangemTheme {
|
|||
@ReadOnlyComposable
|
||||
get() = LocalTangemTypography3.current
|
||||
|
||||
@Deprecated("Use plain dp")
|
||||
val dimens: TangemDimens
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalTangemDimens.current
|
||||
|
||||
@Deprecated("Use plain dp")
|
||||
val dimens2: TangemDimens2
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
package com.tangem.core.ui.format.bigdecimal
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.core.ui.extensions.SpanStyleReference
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.text.DecimalFormat
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal class BigDecimalCryptoFormatTest {
|
||||
|
|
@ -10,6 +15,7 @@ internal class BigDecimalCryptoFormatTest {
|
|||
private val testLocale = Locale.US
|
||||
private val testLocale2 = Locale.GERMANY
|
||||
private val symbol = "BTC"
|
||||
private val spanStyleStub = SpanStyleReference { SpanStyle() }
|
||||
|
||||
// === defaultAmount() ===
|
||||
|
||||
|
|
@ -125,6 +131,39 @@ internal class BigDecimalCryptoFormatTest {
|
|||
.isEqualTo("12,345,678.11".addSymbolWithSpaceLeft(symbol))
|
||||
}
|
||||
|
||||
// === defaultAmount() styled ===
|
||||
|
||||
@Test
|
||||
fun `GIVEN locale with distinct monetary separator WHEN styled defaultAmount THEN fraction split without crash`() {
|
||||
// Arrange
|
||||
// Regression: fr_CH plain separator is ',' but currency output uses '.' — indexOf(',') returned -1,
|
||||
// and formattedAmount.take(-1) threw IllegalArgumentException
|
||||
val swissLocale = Locale("fr", "CH")
|
||||
val symbols = (NumberFormat.getCurrencyInstance(swissLocale) as DecimalFormat).decimalFormatSymbols
|
||||
Truth.assertThat(symbols.monetaryDecimalSeparator).isNotEqualTo(symbols.decimalSeparator)
|
||||
|
||||
val testValue = BigDecimal("12.34")
|
||||
|
||||
// Act
|
||||
val formatted = testValue.formatStyled {
|
||||
cryptoStyled(
|
||||
symbol = symbol,
|
||||
decimals = 8,
|
||||
spanStyleReference = spanStyleStub,
|
||||
locale = swissLocale,
|
||||
)
|
||||
}
|
||||
|
||||
// Assert
|
||||
val refs = (formatted as TextReference.Combined).refs.data
|
||||
Truth.assertThat(refs).hasSize(2)
|
||||
Truth.assertThat((refs[0] as TextReference.Str).value).isEqualTo("12")
|
||||
|
||||
val fraction = refs[1] as TextReference.StyledStr
|
||||
Truth.assertThat(fraction.value).startsWith("${symbols.monetaryDecimalSeparator}34")
|
||||
Truth.assertThat(fraction.value).endsWith(symbol)
|
||||
}
|
||||
|
||||
// === shorted() ===
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
package com.tangem.core.ui.format.bigdecimal
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.core.ui.extensions.SpanStyleReference
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.Arguments
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import java.math.BigDecimal
|
||||
import java.text.DecimalFormat
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal class BigDecimalFiatFormatTest {
|
||||
|
|
@ -16,6 +21,8 @@ internal class BigDecimalFiatFormatTest {
|
|||
val usdCurrencyCode = "USD"
|
||||
val usdSymbol = "$"
|
||||
|
||||
private val spanStyleStub = SpanStyleReference { SpanStyle() }
|
||||
|
||||
private fun String.addUsdSymbolLeft() = usdSymbol + this
|
||||
|
||||
// === defaultAmount() ===
|
||||
|
|
@ -132,6 +139,40 @@ internal class BigDecimalFiatFormatTest {
|
|||
.isEqualTo("-" + "0.01".addUsdSymbolLeft())
|
||||
}
|
||||
|
||||
// === defaultAmount() styled ===
|
||||
|
||||
@Test
|
||||
fun `GIVEN locale with distinct monetary separator WHEN styled defaultAmount THEN fraction split at monetary separator`() {
|
||||
// Arrange
|
||||
// fr_CH plain separator is ',' but currency output uses '.' — searching for the plain one
|
||||
// failed to split the amount into whole and styled fractional parts
|
||||
val swissLocale = Locale("fr", "CH")
|
||||
val symbols = (NumberFormat.getCurrencyInstance(swissLocale) as DecimalFormat).decimalFormatSymbols
|
||||
Truth.assertThat(symbols.monetaryDecimalSeparator).isNotEqualTo(symbols.decimalSeparator)
|
||||
|
||||
val testValue = BigDecimal("12.34")
|
||||
|
||||
// Act
|
||||
val formatted = testValue.formatStyled {
|
||||
fiat(
|
||||
fiatCurrencyCode = usdCurrencyCode,
|
||||
fiatCurrencySymbol = usdSymbol,
|
||||
spanStyleReference = spanStyleStub,
|
||||
locale = swissLocale,
|
||||
)
|
||||
}
|
||||
|
||||
// Assert
|
||||
val refs = (formatted as TextReference.Combined).refs.data
|
||||
Truth.assertThat(refs).hasSize(3)
|
||||
Truth.assertThat(refs[0]).isEqualTo(TextReference.EMPTY)
|
||||
Truth.assertThat((refs[1] as TextReference.Str).value).isEqualTo("12")
|
||||
|
||||
val fraction = refs[2] as TextReference.StyledStr
|
||||
Truth.assertThat(fraction.value).startsWith("${symbols.monetaryDecimalSeparator}34")
|
||||
Truth.assertThat(fraction.value).endsWith(usdSymbol)
|
||||
}
|
||||
|
||||
// === approximateAmount() ===
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -2,17 +2,19 @@
|
|||
|
||||
Generates Kotlin (Jetpack Compose) source files from design tokens and icons defined in the `ds-tokens` git submodule.
|
||||
|
||||
## Making sure submodule is at the pinned commit
|
||||
|
||||
***For the most cases*** (a fresh checkout, or making sure the submodule is at the pinned commit), use:
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
## Updating tokens
|
||||
|
||||
> **Note:** You only need `git submodule update --remote` when you want to pull **new** design tokens
|
||||
> from the remote `ds-tokens` repository. If you're just regenerating Kotlin from the tokens already
|
||||
> checked out (e.g. changing the generation script), **skip step 1** — don't run it without the need,
|
||||
> as it moves the submodule pointer to the latest remote commit and pulls in unrelated token changes.
|
||||
>
|
||||
> For all other cases (a fresh checkout, or making sure the submodule is at the pinned commit), use:
|
||||
> ```bash
|
||||
> git submodule update --init --recursive
|
||||
> ```
|
||||
> This checks out the submodule at the commit already recorded in the repo, without pulling anything new.
|
||||
|
||||
1. *(Only if you need newer tokens)* Update the `ds-tokens` submodule to the latest commit:
|
||||
|
|
|
|||
30
data/marketing/build.gradle.kts
Normal file
30
data/marketing/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
|
|||
commonNetworkStatusFetcher.fetch(
|
||||
userWalletId = params.userWalletId,
|
||||
network = network,
|
||||
networkCurrencies = networksCurrencies[network].orEmpty().toSet(),
|
||||
networkCurrencies = networksCurrencies[network].orEmpty().toSet() + params.extraTokens,
|
||||
xpub = xpubByNetwork[network],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
|
|||
params = MultiNetworkStatusFetcher.Params(
|
||||
userWalletId = params.userWalletId,
|
||||
networks = setOf(params.network),
|
||||
extraTokens = params.extraTokens,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
46
data/promo/build.gradle.kts
Normal file
46
data/promo/build.gradle.kts
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ dependencies {
|
|||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.visa)
|
||||
implementation(projects.domain.virtualAccount)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.legacy)
|
||||
|
|
@ -47,6 +48,7 @@ dependencies {
|
|||
implementation(projects.domain.quotes)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.features.swap.domain)
|
||||
implementation(projects.features.virtualAccounts.details.api)
|
||||
|
||||
|
||||
/** Project - Utils */
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
package com.tangem.data.pay
|
||||
|
||||
import com.tangem.common.card.FirmwareVersion
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.pay.TangemPayEligibilityType
|
||||
import com.tangem.domain.models.pay.isTangemPayType
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.wallet.isLocked
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.models.wallet.isTangemPayCompatible
|
||||
import com.tangem.domain.pay.TangemPayEligibilityManager
|
||||
import com.tangem.domain.pay.model.TangemPayEntryPoint
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.hot.sdk.model.HotWalletId
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
|
@ -85,7 +85,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
val wallets = userWalletsListRepository.userWallets.value ?: return emptyList()
|
||||
|
||||
val candidates = wallets.filter { wallet ->
|
||||
wallet.isMultiCurrency && !wallet.isLocked && wallet.isCompatible() &&
|
||||
wallet.isMultiCurrency && !wallet.isLocked && wallet.isTangemPayCompatible &&
|
||||
!onboardingRepository.isTangemPayDeactivated(wallet.walletId)
|
||||
}
|
||||
|
||||
|
|
@ -98,11 +98,6 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
return candidates
|
||||
}
|
||||
|
||||
private fun UserWallet.isCompatible(): Boolean = when (this) {
|
||||
is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
|
||||
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
|
||||
}
|
||||
|
||||
private suspend fun List<UserWallet>.addPaeraCustomersData(): List<UserWalletData> {
|
||||
if (isEmpty()) return emptyList()
|
||||
|
||||
|
|
@ -139,7 +134,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
onboardingRepository.checkCustomerEligibility()
|
||||
}
|
||||
return if (entryPoint == null) {
|
||||
eligibility.isNotEmpty()
|
||||
eligibility.any { it.isTangemPayType }
|
||||
} else {
|
||||
eligibility.any { it == entryPoint.toEligibilityType() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
|||
)
|
||||
},
|
||||
error = null,
|
||||
virtualAccount = null,
|
||||
)
|
||||
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
|
||||
source = StatusSource.CACHE,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.tangem.domain.models.wallet.UserWallet
|
|||
import com.tangem.domain.pay.WithdrawalSignatureResult
|
||||
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
|
||||
import com.tangem.domain.visa.model.TangemPayInitialCredentials
|
||||
import com.tangem.domain.visa.model.VirtualAccountActivationData
|
||||
import com.tangem.sdk.api.TangemSdkManager
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -26,6 +27,18 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun produceVirtualAccountData(
|
||||
userWallet: UserWallet,
|
||||
): Either<Throwable, VirtualAccountActivationData> {
|
||||
return when (userWallet) {
|
||||
is UserWallet.Cold -> {
|
||||
val preflightReadFilter = UserWalletIdPreflightReadFilter(userWallet.walletId)
|
||||
tangemSdkManager.tangemPayProduceVirtualAccountData(preflightReadFilter = preflightReadFilter)
|
||||
}
|
||||
is UserWallet.Hot -> tangemPayHotSdkManager.produceVirtualAccountData(userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getWithdrawalSignature(
|
||||
userWallet: UserWallet,
|
||||
hash: String,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import arrow.core.getOrElse
|
|||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.common.extensions.hexToBytes
|
||||
import com.tangem.common.extensions.toMapKey
|
||||
import com.tangem.core.error.ext.tangemError
|
||||
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
|
||||
import com.tangem.domain.card.common.visa.VisaUtilities
|
||||
|
|
@ -14,11 +15,13 @@ import com.tangem.domain.visa.datasource.TangemPayRemoteDataSource
|
|||
import com.tangem.domain.visa.error.VisaActivationError
|
||||
import com.tangem.domain.visa.error.VisaCardScanError
|
||||
import com.tangem.domain.visa.model.TangemPayInitialCredentials
|
||||
import com.tangem.domain.visa.model.VirtualAccountActivationData
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessor
|
||||
import com.tangem.hot.sdk.TangemHotSdk
|
||||
import com.tangem.hot.sdk.model.DataToSign
|
||||
import com.tangem.hot.sdk.model.DeriveWalletRequest
|
||||
import com.tangem.hot.sdk.model.UnlockHotWallet
|
||||
import com.tangem.operations.derivation.ExtendedPublicKeysMap
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class TangemPayHotSdkManager @Inject constructor(
|
||||
|
|
@ -56,6 +59,34 @@ internal class TangemPayHotSdkManager @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
suspend fun produceVirtualAccountData(hotWallet: UserWallet.Hot): Either<Throwable, VirtualAccountActivationData> =
|
||||
withUnlockedHotWallet(hotWallet) { unlockHotWallet ->
|
||||
val response = tangemHotSdk.derivePublicKey(
|
||||
unlockHotWallet = unlockHotWallet,
|
||||
request = DeriveWalletRequest(
|
||||
requests = listOf(
|
||||
DeriveWalletRequest.Request(
|
||||
curve = VisaUtilities.curve,
|
||||
paths = listOf(VisaUtilities.virtualAccountDerivationPath),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val curveResponse = response.responses.firstOrNull { it.curve == VisaUtilities.curve }
|
||||
?: raise(VisaActivationError.MissingWallet.tangemError)
|
||||
val extendedPublicKey = curveResponse.publicKeys[VisaUtilities.virtualAccountDerivationPath]
|
||||
?: raise(VisaActivationError.MissingWallet.tangemError)
|
||||
|
||||
VirtualAccountActivationData(
|
||||
address = VisaUtilities.generateAddressFromExtendedKey(extendedPublicKey),
|
||||
derivedKeys = mapOf(
|
||||
curveResponse.seedKey.publicKey.toMapKey() to ExtendedPublicKeysMap(
|
||||
mapOf(VisaUtilities.virtualAccountDerivationPath to extendedPublicKey),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getWithdrawalSignature(
|
||||
hotWallet: UserWallet.Hot,
|
||||
hash: String,
|
||||
|
|
|
|||
|
|
@ -293,5 +293,20 @@ internal interface TangemPayDataModule {
|
|||
appCoroutineScope = appCoroutineScope,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideCreateVirtualAccountOrderUseCase(
|
||||
onboardingRepository: OnboardingRepository,
|
||||
pollingUseCase: StartTangemPayOrderPollingUseCase,
|
||||
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
|
||||
appCoroutineScope: AppCoroutineScope,
|
||||
): CreateVirtualAccountOrderUseCase {
|
||||
return CreateVirtualAccountOrderUseCase(
|
||||
onboardingRepository = onboardingRepository,
|
||||
pollingUseCase = pollingUseCase,
|
||||
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
|
||||
appCoroutineScope = appCoroutineScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,9 @@ import com.tangem.data.common.currency.CryptoCurrencyFactory
|
|||
import com.tangem.data.common.network.NetworkFactory
|
||||
import com.tangem.domain.card.common.visa.VisaUtilities
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.common.wallets.requireUserWalletsSync
|
||||
import com.tangem.domain.common.wallets.getSyncStrict
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.TangemPayCurrencyFactory
|
||||
import javax.inject.Inject
|
||||
|
|
@ -23,9 +24,7 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor(
|
|||
}
|
||||
|
||||
override fun create(userWalletId: UserWalletId): CryptoCurrency.Token {
|
||||
val userWallet = userWalletsListRepository.requireUserWalletsSync()
|
||||
.firstOrNull { it.walletId == userWalletId }
|
||||
?: error("User wallet with id $userWalletId not found")
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
val network = networkFactory.create(
|
||||
blockchain = VisaUtilities.visaBlockchain,
|
||||
userWallet = userWallet,
|
||||
|
|
@ -40,4 +39,21 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor(
|
|||
decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS,
|
||||
)
|
||||
}
|
||||
|
||||
override fun createVirtualAccountToken(userWalletId: UserWalletId): CryptoCurrency.Token {
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
val network = networkFactory.create(
|
||||
blockchain = VisaUtilities.visaBlockchain,
|
||||
derivationPath = Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath),
|
||||
userWallet = userWallet,
|
||||
)
|
||||
return cryptoCurrencyFactory.createToken(
|
||||
network = requireNotNull(network),
|
||||
rawId = TangemPayCurrencyFactory.TOKEN_ID,
|
||||
name = TangemPayCurrencyFactory.TOKEN_NAME,
|
||||
symbol = TangemPayCurrencyFactory.TOKEN_NAME,
|
||||
contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS,
|
||||
decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,21 +4,16 @@ import arrow.core.Either
|
|||
import com.tangem.data.pay.store.PaymentAccountStatusesStore
|
||||
import com.tangem.domain.core.utils.catchOn
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.account.hasAccountData
|
||||
import com.tangem.domain.models.account.*
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.pay.TangemPayCard
|
||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimitData
|
||||
import com.tangem.domain.models.pay.TangemPayCardState
|
||||
import com.tangem.domain.models.pay.*
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.TangemPayCurrencyFactory
|
||||
import com.tangem.domain.pay.TangemPayEligibilityManager
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDataType
|
||||
import com.tangem.domain.pay.model.OrderData
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.TangemPayEntryPoint
|
||||
|
|
@ -26,6 +21,7 @@ import com.tangem.domain.pay.repository.*
|
|||
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
|
||||
import com.tangem.security.DeviceSecurityInfoProvider
|
||||
import com.tangem.security.isSecurityExposed
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -66,6 +62,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
private val closeCardRepository: TangemPayCloseCardRepository,
|
||||
private val cardDetailsRepository: TangemPayCardDetailsRepository,
|
||||
private val issueCardRepository: TangemPayIssueCardRepository,
|
||||
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
|
||||
) : PaymentAccountStatusFetcher {
|
||||
|
||||
private val logger = TangemLogger.withTag(TAG)
|
||||
|
|
@ -114,6 +111,10 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
logger.i("invoke() end ${params.userWalletId}: isRight=${result.isRight()}")
|
||||
}
|
||||
|
||||
override suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
|
||||
paymentAccountStatusesStore.markVirtualAccountProcessing(userWalletId)
|
||||
}
|
||||
|
||||
private suspend fun proceedHasTangemPayResult(
|
||||
account: Account.Payment,
|
||||
hasTangemPay: Boolean,
|
||||
|
|
@ -341,7 +342,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
fiatRate: BigDecimal?,
|
||||
): PaymentAccountStatusValue {
|
||||
val cardsById = cards.associateBy { it.cardId }
|
||||
val tangemPayCards = productInstances.mapNotNull { productInstance ->
|
||||
val tangemPayCards = cardProductInstances.mapNotNull { productInstance ->
|
||||
val cardInfo = cardsById[productInstance.cardId] ?: return@mapNotNull null
|
||||
val cardId = productInstance.cardId
|
||||
val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(cardId)
|
||||
|
|
@ -376,6 +377,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
// the previously shown order and append newly seen cards at the end.
|
||||
val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId))
|
||||
|
||||
val virtualAccount = resolveVirtualAccountOnramp(userWalletId)
|
||||
|
||||
return PaymentAccountStatusValue.Loaded(
|
||||
source = StatusSource.ACTUAL,
|
||||
customerId = customerId,
|
||||
|
|
@ -389,6 +392,84 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
availableForWithdrawal = availableForWithdrawal.orZero(),
|
||||
),
|
||||
error = null,
|
||||
virtualAccount = virtualAccount,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Virtual Account on-ramp dimension (VA MVP0, TWI-1638). Gated by the feature toggle.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. A product instance with [SpecificationDataType.ACCOUNT] exists — clears any stale persisted VA order id
|
||||
* (idempotent) and eagerly fetches its bank credentials ([VirtualAccountOnramp.Available], or
|
||||
* [VirtualAccountOnramp.BankCredentialsError] on failure).
|
||||
* 2. Otherwise, a VA order id is persisted locally — checks its status via `getOrderData`:
|
||||
* NEW/PROCESSING/COMPLETED (or a lookup failure) surface [VirtualAccountOnramp.Processing]; CANCELED
|
||||
* clears the persisted id and falls through to eligibility.
|
||||
* 3. Otherwise (or after a CANCELED order) — surfaces [VirtualAccountOnramp.Eligible] when the wallet has
|
||||
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else `null`.
|
||||
*/
|
||||
private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp? {
|
||||
if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return null
|
||||
|
||||
val accountInstance = productInstances.firstOrNull {
|
||||
it.specificationDataType == SpecificationDataType.ACCOUNT
|
||||
}
|
||||
if (accountInstance != null) {
|
||||
// Order provisioned into an ACCOUNT product instance — drop the in-flight order hint (idempotent).
|
||||
onboardingRepository.clearVirtualAccountOrderId(userWalletId)
|
||||
return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("getBankCredentials failed for ${accountInstance.id}: $error")
|
||||
VirtualAccountOnramp.BankCredentialsError
|
||||
},
|
||||
ifRight = { credentials ->
|
||||
VirtualAccountOnramp.Available(
|
||||
productInstanceId = accountInstance.id,
|
||||
bankCredentials = credentials,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val vaOrderId = onboardingRepository.getVirtualAccountOrderId(userWalletId)
|
||||
if (vaOrderId != null) {
|
||||
return customerOrderRepository.getOrderData(userWalletId = userWalletId, orderId = vaOrderId).fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("getOrderData(va) failed for $vaOrderId: $error")
|
||||
VirtualAccountOnramp.Processing
|
||||
},
|
||||
ifRight = { orderData ->
|
||||
when (orderData.status) {
|
||||
OrderStatus.CANCELED -> {
|
||||
onboardingRepository.clearVirtualAccountOrderId(userWalletId)
|
||||
resolveEligibility(userWalletId)
|
||||
}
|
||||
OrderStatus.NEW,
|
||||
OrderStatus.PROCESSING,
|
||||
OrderStatus.COMPLETED,
|
||||
-> VirtualAccountOnramp.Processing
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return resolveEligibility(userWalletId)
|
||||
}
|
||||
|
||||
private suspend fun resolveEligibility(userWalletId: UserWalletId): VirtualAccountOnramp? {
|
||||
return onboardingRepository.fetchCustomerEligibility(userWalletId).fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("fetchCustomerEligibility failed for $userWalletId: $error")
|
||||
null
|
||||
},
|
||||
ifRight = { channels ->
|
||||
if (channels.contains(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT)) {
|
||||
VirtualAccountOnramp.Eligible
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import arrow.core.left
|
|||
import arrow.core.right
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.data.pay.store.PaymentAccountStatusesStore
|
||||
import com.tangem.data.pay.util.BankCredentialsConverter
|
||||
import com.tangem.data.pay.util.CustomerInfoConverter
|
||||
import com.tangem.datasource.api.pay.TangemPayApi
|
||||
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
|
||||
import com.tangem.datasource.api.pay.models.request.OrderRequest
|
||||
import com.tangem.datasource.api.pay.models.request.SetTangemPayEnabledRequest
|
||||
import com.tangem.datasource.api.pay.models.request.VirtualAccountOrderRequest
|
||||
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
|
||||
import com.tangem.datasource.api.pay.models.response.OrderResponse
|
||||
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
|
||||
|
|
@ -18,6 +20,7 @@ import com.tangem.datasource.local.visa.TangemPayStorage
|
|||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.BankCredentials
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.pay.TangemPayEligibilityType
|
||||
|
|
@ -36,7 +39,7 @@ import javax.inject.Inject
|
|||
|
||||
private const val VALID_STATUS = "valid"
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
internal class DefaultOnboardingRepository @Inject constructor(
|
||||
private val analytics: AnalyticsEventHandler,
|
||||
private val dispatcherProvider: CoroutineDispatcherProvider,
|
||||
|
|
@ -105,6 +108,18 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun getBankCredentials(
|
||||
userWalletId: UserWalletId,
|
||||
productInstanceId: String,
|
||||
): Either<VisaApiError, BankCredentials> {
|
||||
return requestHelper.performRequest(userWalletId) { authHeader ->
|
||||
tangemPayApi.getBankCredentials(authHeader = authHeader, productInstanceId = productInstanceId)
|
||||
}.flatMap { response ->
|
||||
val result = response.result ?: return@flatMap VisaApiError.UnknownWithoutCode.left()
|
||||
BankCredentialsConverter.convert(result).right()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean {
|
||||
return tangemPayStorage.isTangemPayDeactivated(userWalletId)
|
||||
}
|
||||
|
|
@ -156,6 +171,42 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun createVirtualAccountOrder(
|
||||
userWalletId: UserWalletId,
|
||||
paymentAccountAddress: String,
|
||||
idempotencyKey: String,
|
||||
): Either<VisaApiError, String> = withContext(dispatcherProvider.io) {
|
||||
requestHelper.performRequest(userWalletId) { authHeader ->
|
||||
tangemPayApi.createVirtualAccountOrder(
|
||||
authHeader = authHeader,
|
||||
body = VirtualAccountOrderRequest(
|
||||
data = VirtualAccountOrderRequest.Data(depositAddress = paymentAccountAddress),
|
||||
idempotencyKey = idempotencyKey,
|
||||
),
|
||||
)
|
||||
}.map { response -> requireNotNull(response.result).id }
|
||||
}
|
||||
|
||||
override suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
|
||||
tangemPayStorage.getVirtualAccountOrderId(customerWalletAddress)
|
||||
}
|
||||
|
||||
override suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String) {
|
||||
withContext(dispatcherProvider.io) {
|
||||
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
|
||||
tangemPayStorage.storeVirtualAccountOrderId(customerWalletAddress, vaOrderId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId) {
|
||||
withContext(dispatcherProvider.io) {
|
||||
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
|
||||
tangemPayStorage.clearVirtualAccountOrderId(customerWalletAddress)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
|
||||
return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
|
||||
?: error("no userWallet found")
|
||||
|
|
@ -170,7 +221,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
sendKycAnalytics(customerInfo.kycStatus)
|
||||
|
||||
// Keep the per-card frozen state up to date for every card.
|
||||
customerInfo.productInstances.forEach { instance ->
|
||||
customerInfo.cardProductInstances.forEach { instance ->
|
||||
cardFrozenStateStore.store(key = instance.cardId, value = instance.frozenState)
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +277,16 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
return tangemPayStorage.getTangemPayEligibility().map(TangemPayEligibilityType::fromString)
|
||||
}
|
||||
|
||||
override suspend fun fetchCustomerEligibility(
|
||||
userWalletId: UserWalletId,
|
||||
): Either<VisaApiError, List<TangemPayEligibilityType>> {
|
||||
return requestHelper.performRequest(userWalletId) { authHeader ->
|
||||
tangemPayApi.getUserEligibilityChannels(authHeader)
|
||||
}.map { response ->
|
||||
response.result.channels.map(TangemPayEligibilityType::fromString)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {
|
||||
return tangemPayStorage.getHideMainOnboardingBanner(userWalletId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.tangem.domain.models.StatusSource
|
|||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.account.VirtualAccountOnramp
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
|
|
@ -89,6 +90,28 @@ internal class PaymentAccountStatusesStore(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically marks the cached VA on-ramp as [VirtualAccountOnramp.Processing] so the UI reflects a
|
||||
|
||||
* read-modify-write atomically inside [RuntimeSharedStore.update] to avoid a lost update racing with a
|
||||
* concurrent [store]/[updateStatusSource] call. No-op (no write) when there is no cached entry for
|
||||
* [userWalletId], or when its value isn't [PaymentAccountStatusValue.Loaded]. Not persisted, mirroring
|
||||
* [updateStatusSource].
|
||||
*/
|
||||
suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
|
||||
logger.i("markVirtualAccountProcessing($userWalletId)")
|
||||
runtimeStore.update(emptyMap()) { stored ->
|
||||
stored.toMutableMap().apply {
|
||||
val paymentAccountStatus = this[userWalletId.stringValue] ?: return@update stored
|
||||
val loaded = paymentAccountStatus.value as? PaymentAccountStatusValue.Loaded ?: return@update stored
|
||||
val newValue = paymentAccountStatus.copy(
|
||||
value = loaded.copy(virtualAccount = VirtualAccountOnramp.Processing),
|
||||
)
|
||||
put(key = userWalletId.stringValue, value = newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Payment) {
|
||||
logger.i("store($userWalletId): valueType=${status.value::class.simpleName}")
|
||||
coroutineScope {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
|
|||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
|
||||
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
|
||||
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDataType
|
||||
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
|
|
@ -56,12 +57,13 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
|
|||
val name = displayName?.ifEmpty { null }
|
||||
return ProductInstance(
|
||||
id = id,
|
||||
cardId = cardId,
|
||||
cardId = cardId.orEmpty(),
|
||||
frozenState = cardFrozenState,
|
||||
status = status,
|
||||
displayName = if (name != null) CardDisplayName(name).getOrElse { null } else null,
|
||||
actualCardLimit = actualCardLimit?.parseCardLimit(),
|
||||
adminCardLimit = adminCardLimit?.parseCardLimit(),
|
||||
specificationDataType = specificationDataType.toDomain(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -108,4 +110,10 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
|
|||
CustomerMeResponse.ProductInstance.Status.CANCELED -> Status.CANCELED
|
||||
CustomerMeResponse.ProductInstance.Status.UNKNOWN -> Status.UNKNOWN
|
||||
}
|
||||
|
||||
private fun CustomerMeResponse.ProductInstance.SpecificationDataType.toDomain(): SpecificationDataType =
|
||||
when (this) {
|
||||
CustomerMeResponse.ProductInstance.SpecificationDataType.ACCOUNT -> SpecificationDataType.ACCOUNT
|
||||
CustomerMeResponse.ProductInstance.SpecificationDataType.CARD -> SpecificationDataType.CARD
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import com.tangem.datasource.api.pay.models.response.TangemPayErrorResponse
|
|||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ internal class TangemPayErrorConverter @Inject constructor(
|
|||
if (value.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) return VisaApiError.RefreshTokenExpired
|
||||
|
||||
val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode
|
||||
return runCatching {
|
||||
runCatching {
|
||||
tangemPayErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode
|
||||
}.map {
|
||||
VisaApiError.fromBackendError(it)
|
||||
|
|
@ -31,6 +32,7 @@ internal class TangemPayErrorConverter @Inject constructor(
|
|||
VisaApiError.UnknownWithoutCode
|
||||
}
|
||||
} else {
|
||||
TangemLogger.e("Not HttpException. ${value.message}", value)
|
||||
VisaApiError.UnknownWithoutCode
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.squareup.moshi.Moshi
|
|||
import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter
|
||||
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher
|
||||
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer
|
||||
import com.tangem.data.virtualaccount.repository.DefaultVirtualAccountActivationRepository
|
||||
import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
|
|
@ -17,6 +18,13 @@ import com.tangem.datasource.utils.mapWithStringKeyTypes
|
|||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository
|
||||
import com.tangem.domain.virtualaccount.usecase.ActivateVirtualAccountUseCase
|
||||
import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountEligibilityUseCase
|
||||
import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountSuitableWalletsUseCase
|
||||
import com.tangem.security.DeviceSecurityInfoProvider
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
|
@ -40,6 +48,12 @@ internal interface VirtualAccountDataModule {
|
|||
@Singleton
|
||||
fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindVirtualAccountActivationRepository(
|
||||
impl: DefaultVirtualAccountActivationRepository,
|
||||
): VirtualAccountActivationRepository
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
|
|
@ -77,5 +91,34 @@ internal interface VirtualAccountDataModule {
|
|||
keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" },
|
||||
) {}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideActivateVirtualAccountUseCase(
|
||||
repository: VirtualAccountActivationRepository,
|
||||
): ActivateVirtualAccountUseCase {
|
||||
return ActivateVirtualAccountUseCase(repository = repository)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetVirtualAccountSuitableWalletsUseCase(
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
): GetVirtualAccountSuitableWalletsUseCase {
|
||||
return GetVirtualAccountSuitableWalletsUseCase(userWalletsListRepository = userWalletsListRepository)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideGetVirtualAccountEligibilityUseCase(
|
||||
getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase,
|
||||
onboardingRepository: OnboardingRepository,
|
||||
deviceSecurityInfoProvider: DeviceSecurityInfoProvider,
|
||||
): GetVirtualAccountEligibilityUseCase {
|
||||
return GetVirtualAccountEligibilityUseCase(
|
||||
getVirtualAccountSuitableWalletsUseCase = getVirtualAccountSuitableWalletsUseCase,
|
||||
onboardingRepository = onboardingRepository,
|
||||
deviceSecurityInfoProvider = deviceSecurityInfoProvider,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue