Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-19 20:56:27 +04:00
parent a8faaffcab
commit 2db9958dd1
17 changed files with 120 additions and 83 deletions

View file

@ -4,5 +4,4 @@ import com.squareup.moshi.Json
data class CardBalanceResponse(
@Json(name = "result") val result: BalanceResponse?,
@Json(name = "error") val error: String?,
)

View file

@ -5,7 +5,6 @@ import com.squareup.moshi.JsonClass
data class CardDetailsResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(

View file

@ -6,7 +6,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CheckCustomerWalletResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
data class Result(
@Json(name = "id") val id: String?,

View file

@ -6,7 +6,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CustomerEligibilityResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(

View file

@ -6,7 +6,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CustomerMeResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(

View file

@ -6,7 +6,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DeeplinkValidityResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(

View file

@ -6,7 +6,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
class FreezeUnfreezeCardResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(

View file

@ -6,7 +6,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class OrderResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(

View file

@ -7,7 +7,6 @@ import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class TangemPayTxHistoryResponse(
@Json(name = "error") val error: String?,
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)

View file

@ -6,7 +6,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class WithdrawDataResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(

View file

@ -6,7 +6,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class WithdrawResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(

View file

@ -9,26 +9,43 @@ import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
internal class DefaultTangemPayEligibilityManager @Inject constructor(
dispatchers: CoroutineDispatcherProvider,
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val onboardingRepository: OnboardingRepository,
) : TangemPayEligibilityManager {
private var cachedEligibleWallets: List<UserWallet>? = null
private var eligibleWalletsDeferred: Deferred<List<UserWallet>>? = null
private var cachedEligibleWallets: List<UserWalletData>? = null
private var eligibleWalletsDeferred: Deferred<List<UserWalletData>>? = null
private val loadMutex = Mutex()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
override suspend fun getEligibleWallets(): List<UserWallet> {
init {
resetDataWhenWalletsUpdate()
}
override suspend fun getEligibleWallets(shouldExcludePaeraCustomers: Boolean): List<UserWallet> {
return getUserWalletsData().mapNotNull {
if (!it.isPaeraCustomer || !shouldExcludePaeraCustomers) it.userWallet else null
}
}
private suspend fun getUserWalletsData(): List<UserWalletData> {
cachedEligibleWallets?.let { return it }
return loadMutex.withLock {
@ -38,7 +55,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
coroutineScope {
val deferred = async {
getPossibleWalletsForTangemPay()
.excludePaeraCustomers()
.addPaeraCustomersData()
.also { cachedEligibleWallets = it }
}
eligibleWalletsDeferred = deferred
@ -73,8 +90,8 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
is UserWallet.Hot -> true
}
private suspend fun List<UserWallet>.excludePaeraCustomers(): List<UserWallet> {
if (isEmpty()) return this
private suspend fun List<UserWallet>.addPaeraCustomersData(): List<UserWalletData> {
if (isEmpty()) return emptyList()
return coroutineScope {
map { wallet ->
@ -86,9 +103,30 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
}
}
.awaitAll()
.mapNotNull { (wallet, isCustomer) ->
wallet.takeUnless { isCustomer }
.map { (userWallet, isPaeraCustomer) ->
UserWalletData(userWallet, isPaeraCustomer)
}
}
}
private fun resetDataWhenWalletsUpdate() {
coroutineScope.launch {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
userWalletsListRepository.userWallets.collectLatest { reset() }
} else {
userWalletsListManager.userWallets.collectLatest { reset() }
}
}
}
private fun reset() {
cachedEligibleWallets = null
eligibleWalletsDeferred?.cancel()
eligibleWalletsDeferred = null
}
private data class UserWalletData(
val userWallet: UserWallet,
val isPaeraCustomer: Boolean,
)
}

View file

@ -4,5 +4,5 @@ import com.tangem.domain.models.wallet.UserWallet
interface TangemPayEligibilityManager {
suspend fun getEligibleWallets(): List<UserWallet>
suspend fun getEligibleWallets(shouldExcludePaeraCustomers: Boolean): List<UserWallet>
}

View file

@ -46,7 +46,11 @@ class TangemPayMainScreenCustomerInfoUseCase(
.fold(
ifLeft = { error ->
Timber.tag(TAG).e("Failed checkCustomerWallet for $userWalletId: ${error.javaClass.simpleName}")
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
if (error is VisaApiError.NotPaeraCustomer) {
showOnboardingBannerIfEligible(userWalletId)
} else {
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
}
},
ifRight = { hasTangemPay ->
Timber.tag(TAG).i("checkCustomerWallet for $userWalletId: $hasTangemPay")
@ -61,21 +65,27 @@ class TangemPayMainScreenCustomerInfoUseCase(
updateState(userWalletId, result)
} else {
// if there's no tangem pay, check eligibility and show onboarding banner
val isEligible = eligibilityManager.getEligibleWallets().any { it.walletId == userWalletId }
if (isEligible) {
if (onboardingRepository.getHideMainOnboardingBanner(userWalletId)) {
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
} else {
updateState(userWalletId, MainCustomerInfoContentState.OnboardingBanner.right())
}
} else {
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
}
showOnboardingBannerIfEligible(userWalletId)
}
},
)
}
private suspend fun showOnboardingBannerIfEligible(userWalletId: UserWalletId) {
val isEligible = eligibilityManager
.getEligibleWallets(shouldExcludePaeraCustomers = false)
.any { it.walletId == userWalletId }
if (isEligible) {
if (onboardingRepository.getHideMainOnboardingBanner(userWalletId)) {
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
} else {
updateState(userWalletId, MainCustomerInfoContentState.OnboardingBanner.right())
}
} else {
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
}
}
operator fun invoke(
userWalletId: UserWalletId,
): Flow<Either<TangemPayCustomerInfoError, MainCustomerInfoContentState>> {

View file

@ -248,7 +248,9 @@ internal class DetailsModel @Inject constructor(
private fun addTangemPayItemIfEligible() {
if (!tangemPayFeatureToggles.isTangemPayEnabled) return
modelScope.launch {
val isEligible = tangemPayEligibilityManager.getEligibleWallets().isNotEmpty()
val isEligible = tangemPayEligibilityManager
.getEligibleWallets(shouldExcludePaeraCustomers = true)
.isNotEmpty()
if (isEligible) {
items.update { itemsBuilder.addVisaItem(it) }
}

View file

@ -65,7 +65,9 @@ internal class TangemPayOnboardingModel @Inject constructor(
.onRight { isValid -> if (isValid) showOnboarding() else back() }
.onLeft { back() }
}
is TangemPayOnboardingComponent.Params.ContinueOnboarding,
is TangemPayOnboardingComponent.Params.ContinueOnboarding -> {
openKyc(userWalletId = params.userWalletId)
}
is TangemPayOnboardingComponent.Params.FromBannerInSettings,
is TangemPayOnboardingComponent.Params.FromBannerOnMain,
-> showOnboarding()
@ -113,8 +115,13 @@ internal class TangemPayOnboardingModel @Inject constructor(
private fun onGetCardClick() {
analytics.send(TangemPayAnalyticsEvents.GetCardClicked())
// if user came from deeplink or banner in settings and already is a paera customer -> exclude this wallet
val shouldExcludePaeraCustomers = params is TangemPayOnboardingComponent.Params.FromBannerInSettings ||
params is TangemPayOnboardingComponent.Params.Deeplink
modelScope.launch {
val eligibleWalletsIds = eligibilityManager.getEligibleWallets().map { it.walletId }
val eligibleWalletsIds = eligibilityManager
.getEligibleWallets(shouldExcludePaeraCustomers = shouldExcludePaeraCustomers)
.map { it.walletId }
if (eligibleWalletsIds.isEmpty()) {
back()
return@launch

View file

@ -4,21 +4,9 @@ import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
@ -28,8 +16,9 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -57,28 +46,20 @@ internal fun TangemPayOnboardingBanner(state: TangemPayState.OnboardingBanner, m
)
.clickable(onClick = state.onClick),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
ConstraintLayout(
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(R.drawable.img_tangem_pay_onboarding_banner),
contentDescription = null,
modifier = Modifier
.padding(horizontal = 8.dp)
.width(55.dp)
.height(95.dp)
.offset(y = 10.dp),
)
SpacerH4()
val (image, text, close) = createRefs()
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
modifier = Modifier
.constrainAs(text) {
top.linkTo(parent.top)
start.linkTo(image.end, margin = 12.dp)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
}
.padding(top = 16.dp, end = 16.dp, bottom = 16.dp),
) {
Text(
text = stringResourceSafe(R.string.tangempay_onboarding_banner_title),
@ -94,22 +75,32 @@ internal fun TangemPayOnboardingBanner(state: TangemPayState.OnboardingBanner, m
color = TangemTheme.colors.text.tertiary,
)
}
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.TopEnd,
) {
Image(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable(onClick = state.closeOnClick)
.padding(4.dp)
.padding(top = 12.dp)
.size(12.dp),
painter = painterResource(id = R.drawable.ic_close_24),
colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive),
contentDescription = null,
)
}
Image(
painter = painterResource(R.drawable.img_tangem_pay_onboarding_banner),
contentDescription = null,
modifier = Modifier
.constrainAs(image) {
start.linkTo(parent.start)
top.linkTo(text.top)
bottom.linkTo(text.bottom)
height = Dimension.fillToConstraints
}
.padding(top = 8.dp, start = 24.dp),
)
Image(
painter = painterResource(R.drawable.ic_close_24),
contentDescription = null,
modifier = Modifier
.size(16.dp)
.clickable(onClick = state.closeOnClick)
.constrainAs(close) {
top.linkTo(parent.top, margin = 16.dp)
end.linkTo(parent.end, margin = 16.dp)
},
colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive),
)
}
}
}