Updated on 2026-08-14
This commit is contained in:
commit
ace91fb278
43 changed files with 965 additions and 197 deletions
|
|
@ -48,6 +48,7 @@ class MoshiModule {
|
|||
)
|
||||
.add(
|
||||
NamePolymorphicAdapterFactory.of(PaymentAccountStatusValueDM::class.java)
|
||||
.withSubtype(PaymentAccountStatusValueDM.Empty::class.java, "empty")
|
||||
.withSubtype(PaymentAccountStatusValueDM.NotCreated::class.java, "not_created")
|
||||
.withSubtype(PaymentAccountStatusValueDM.UnderReview::class.java, "kyc_status")
|
||||
.withSubtype(PaymentAccountStatusValueDM.IssuingCard::class.java, "issuing_card")
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ import java.math.BigDecimal
|
|||
@JsonClass(generateAdapter = true, generator = PolymorphicAdapterType.NAME_POLYMORPHIC_ADAPTER)
|
||||
sealed interface PaymentAccountStatusValueDM {
|
||||
|
||||
@NameLabel("empty")
|
||||
data class Empty(
|
||||
@Json(name = "empty") val marker: Boolean = true,
|
||||
) : PaymentAccountStatusValueDM
|
||||
|
||||
@NameLabel("not_created")
|
||||
data class NotCreated(
|
||||
@Json(name = "not_created") val marker: Boolean = true,
|
||||
|
|
|
|||
|
|
@ -10,15 +10,15 @@ class PeriodicTask<T>(
|
|||
private val task: suspend () -> Result<T>,
|
||||
private val onSuccess: (T) -> Unit,
|
||||
private val onError: (Throwable) -> Unit,
|
||||
private val isDelayFirst: Boolean = false,
|
||||
private val initialDelay: Long = 0L,
|
||||
) {
|
||||
|
||||
private var isActive: AtomicBoolean = AtomicBoolean(false)
|
||||
|
||||
suspend fun runTaskWithDelay() {
|
||||
isActive.set(true)
|
||||
if (isDelayFirst) {
|
||||
delay(delay)
|
||||
if (initialDelay > 0L) {
|
||||
delay(initialDelay)
|
||||
}
|
||||
while (isActive.get()) {
|
||||
task.invoke()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
package com.tangem.utils.coroutines
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PeriodicTaskTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN initialDelay 0 and delay 1000 WHEN runTaskWithDelay THEN task is invoked immediately`() = runTest {
|
||||
val callCount = AtomicInteger(0)
|
||||
val onSuccess = mockk<(Int) -> Unit>(relaxed = true)
|
||||
val onError = mockk<(Throwable) -> Unit>(relaxed = true)
|
||||
val periodicTask = PeriodicTask(
|
||||
delay = PERIOD,
|
||||
task = { callCount.incrementAndGet(); Result.success(VALUE) },
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
initialDelay = 0L,
|
||||
)
|
||||
|
||||
launch { periodicTask.runTaskWithDelay() }
|
||||
runCurrent()
|
||||
|
||||
assertThat(callCount.get()).isEqualTo(1)
|
||||
periodicTask.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initialDelay 1000 WHEN runTaskWithDelay THEN task is not invoked before initialDelay elapses`() =
|
||||
runTest {
|
||||
val callCount = AtomicInteger(0)
|
||||
val periodicTask = PeriodicTask(
|
||||
delay = PERIOD,
|
||||
task = { callCount.incrementAndGet(); Result.success(VALUE) },
|
||||
onSuccess = mockk(relaxed = true),
|
||||
onError = mockk(relaxed = true),
|
||||
initialDelay = INITIAL_DELAY,
|
||||
)
|
||||
|
||||
launch { periodicTask.runTaskWithDelay() }
|
||||
advanceTimeBy(INITIAL_DELAY - 1)
|
||||
|
||||
assertThat(callCount.get()).isEqualTo(0)
|
||||
periodicTask.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initialDelay 1000 WHEN runTaskWithDelay THEN task is invoked once initialDelay elapses`() = runTest {
|
||||
val callCount = AtomicInteger(0)
|
||||
val periodicTask = PeriodicTask(
|
||||
delay = PERIOD,
|
||||
task = { callCount.incrementAndGet(); Result.success(VALUE) },
|
||||
onSuccess = mockk(relaxed = true),
|
||||
onError = mockk(relaxed = true),
|
||||
initialDelay = INITIAL_DELAY,
|
||||
)
|
||||
|
||||
launch { periodicTask.runTaskWithDelay() }
|
||||
advanceTimeBy(INITIAL_DELAY)
|
||||
runCurrent()
|
||||
|
||||
assertThat(callCount.get()).isEqualTo(1)
|
||||
periodicTask.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN periodic task WHEN runTaskWithDelay THEN task is invoked repeatedly every delay ms`() = runTest {
|
||||
val callCount = AtomicInteger(0)
|
||||
val periodicTask = PeriodicTask(
|
||||
delay = PERIOD,
|
||||
task = { callCount.incrementAndGet(); Result.success(VALUE) },
|
||||
onSuccess = mockk(relaxed = true),
|
||||
onError = mockk(relaxed = true),
|
||||
initialDelay = 0L,
|
||||
)
|
||||
|
||||
launch { periodicTask.runTaskWithDelay() }
|
||||
runCurrent()
|
||||
advanceTimeBy(PERIOD * 3)
|
||||
runCurrent()
|
||||
|
||||
assertThat(callCount.get()).isEqualTo(4)
|
||||
periodicTask.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN successful task WHEN runTaskWithDelay THEN onSuccess is called with the result value`() = runTest {
|
||||
val received = AtomicInteger(-1)
|
||||
val errors = AtomicInteger(0)
|
||||
val periodicTask = PeriodicTask(
|
||||
delay = PERIOD,
|
||||
task = { Result.success(VALUE) },
|
||||
onSuccess = { received.set(it) },
|
||||
onError = { errors.incrementAndGet() },
|
||||
initialDelay = 0L,
|
||||
)
|
||||
|
||||
launch { periodicTask.runTaskWithDelay() }
|
||||
runCurrent()
|
||||
|
||||
assertThat(received.get()).isEqualTo(VALUE)
|
||||
assertThat(errors.get()).isEqualTo(0)
|
||||
periodicTask.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN failing task WHEN runTaskWithDelay THEN onError is called with the thrown exception`() = runTest {
|
||||
val boom = IllegalStateException("boom")
|
||||
val captured = arrayOfNulls<Throwable>(1)
|
||||
val successes = AtomicInteger(0)
|
||||
val periodicTask = PeriodicTask<Int>(
|
||||
delay = PERIOD,
|
||||
task = { Result.failure(boom) },
|
||||
onSuccess = { successes.incrementAndGet() },
|
||||
onError = { captured[0] = it },
|
||||
initialDelay = 0L,
|
||||
)
|
||||
|
||||
launch { periodicTask.runTaskWithDelay() }
|
||||
runCurrent()
|
||||
|
||||
assertThat(captured[0]).isSameInstanceAs(boom)
|
||||
assertThat(successes.get()).isEqualTo(0)
|
||||
periodicTask.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN task running WHEN cancel THEN no further invocations happen`() = runTest {
|
||||
val callCount = AtomicInteger(0)
|
||||
val periodicTask = PeriodicTask(
|
||||
delay = PERIOD,
|
||||
task = { callCount.incrementAndGet(); Result.success(VALUE) },
|
||||
onSuccess = mockk(relaxed = true),
|
||||
onError = mockk(relaxed = true),
|
||||
initialDelay = 0L,
|
||||
)
|
||||
|
||||
launch { periodicTask.runTaskWithDelay() }
|
||||
runCurrent()
|
||||
assertThat(callCount.get()).isEqualTo(1)
|
||||
|
||||
periodicTask.cancel()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(callCount.get()).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initialDelay 1000 and cancel before it elapses WHEN runTaskWithDelay THEN task is never invoked`() =
|
||||
runTest {
|
||||
val callCount = AtomicInteger(0)
|
||||
val periodicTask = PeriodicTask(
|
||||
delay = PERIOD,
|
||||
task = { callCount.incrementAndGet(); Result.success(VALUE) },
|
||||
onSuccess = mockk(relaxed = true),
|
||||
onError = mockk(relaxed = true),
|
||||
initialDelay = INITIAL_DELAY,
|
||||
)
|
||||
|
||||
launch { periodicTask.runTaskWithDelay() }
|
||||
advanceTimeBy(INITIAL_DELAY / 2)
|
||||
periodicTask.cancel()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(callCount.get()).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN task cancelled during invocation WHEN runTaskWithDelay THEN onSuccess is not called for the pending result`() =
|
||||
runTest {
|
||||
val onSuccess = mockk<(Int) -> Unit>(relaxed = true)
|
||||
val periodicTask = PeriodicTask(
|
||||
delay = PERIOD,
|
||||
// Simulates a slow task that completes after the scheduler was cancelled.
|
||||
task = {
|
||||
delay(PERIOD)
|
||||
Result.success(VALUE)
|
||||
},
|
||||
onSuccess = onSuccess,
|
||||
onError = mockk(relaxed = true),
|
||||
initialDelay = 0L,
|
||||
)
|
||||
|
||||
launch { periodicTask.runTaskWithDelay() }
|
||||
runCurrent()
|
||||
periodicTask.cancel()
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(exactly = 0) { onSuccess.invoke(any()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PERIOD = 10_000L
|
||||
const val INITIAL_DELAY = 1_000L
|
||||
const val VALUE = 42
|
||||
}
|
||||
}
|
||||
|
|
@ -48,9 +48,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
}
|
||||
|
||||
override suspend fun getTangemPayAvailability(entryPoint: TangemPayEntryPoint): Boolean {
|
||||
val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty {
|
||||
onboardingRepository.checkCustomerEligibility()
|
||||
}
|
||||
val eligibility = onboardingRepository.checkCustomerEligibility()
|
||||
val type = entryPoint.toEligibilityType()
|
||||
return eligibility.any { it == type }
|
||||
.also { isEligible -> if (!isEligible) reset() }
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
|||
is PaymentAccountStatusValue.Error.CardIssueFailed -> PaymentAccountStatusValueDM.CardIssueFailed(
|
||||
customerId = value.customerId,
|
||||
)
|
||||
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
|
||||
// Transient statuses are not persisted
|
||||
is PaymentAccountStatusValue.Loading,
|
||||
is PaymentAccountStatusValue.Error.ExposedDevice,
|
||||
|
|
@ -67,6 +68,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
|||
|
||||
fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue {
|
||||
return when (value) {
|
||||
is PaymentAccountStatusValueDM.Empty -> PaymentAccountStatusValue.Empty
|
||||
is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated
|
||||
is PaymentAccountStatusValueDM.CardIssueFailed -> PaymentAccountStatusValue.Error.CardIssueFailed(
|
||||
customerId = value.customerId,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
|
|||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.pay.TangemPayCard
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimitData
|
||||
import com.tangem.domain.pay.TangemPayEligibilityManager
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
import com.tangem.domain.pay.model.OrderData
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.TangemPayEntryPoint
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
|
|
@ -32,6 +34,7 @@ import kotlin.time.Duration.Companion.minutes
|
|||
|
||||
private const val TAG = "PaymentAccountStatusFetcher"
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
||||
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
|
||||
private val onboardingRepository: OnboardingRepository,
|
||||
|
|
@ -39,6 +42,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
private val deviceSecurity: DeviceSecurityInfoProvider,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
|
||||
private val eligibilityManager: TangemPayEligibilityManager,
|
||||
) : PaymentAccountStatusFetcher {
|
||||
|
||||
private val logger = TangemLogger.withTag(TAG)
|
||||
|
|
@ -48,6 +52,16 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
val account = Account.Payment(userWalletId = params.userWalletId)
|
||||
logger.i("fetch: ${params.userWalletId.stringValue}")
|
||||
|
||||
if (onboardingRepository.isTangemPayDeactivated(params.userWalletId)) {
|
||||
return@catchOn paymentAccountStatusesStore.store(
|
||||
userWalletId = params.userWalletId,
|
||||
status = AccountStatus.Payment(
|
||||
account = account,
|
||||
value = PaymentAccountStatusValue.Empty,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (deviceSecurity.isSecurityExposed()) {
|
||||
logger.i("fetch security info: rooted: ${deviceSecurity.isRooted}")
|
||||
logger.i("fetch security info: xposed: ${deviceSecurity.isXposed}")
|
||||
|
|
@ -62,22 +76,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
if (onboardingRepository.isTangemPayDeactivated(params.userWalletId)) {
|
||||
return@catchOn paymentAccountStatusesStore.store(
|
||||
userWalletId = params.userWalletId,
|
||||
status = AccountStatus.Payment(
|
||||
account = account,
|
||||
value = PaymentAccountStatusValue.NotCreated,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val status = onboardingRepository.hasTangemPayInWallet(userWalletId = params.userWalletId)
|
||||
.fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("Failed check wallet ${params.userWalletId}: ${error.javaClass.simpleName}")
|
||||
when (error) {
|
||||
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatusValue.NotCreated
|
||||
is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(params.userWalletId)
|
||||
else -> PaymentAccountStatusValue.Error.Unavailable
|
||||
}
|
||||
},
|
||||
|
|
@ -105,7 +109,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
return if (hasTangemPay) {
|
||||
fetchTangemPayAccountStatus(account)
|
||||
} else {
|
||||
PaymentAccountStatusValue.NotCreated
|
||||
constructNotCreatedOrEmptyStatus(account.userWalletId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +142,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
return onboardingRepository.getCustomerInfo(account.userWalletId).fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("proceedWithoutOrder ${account.userWalletId} error: $error")
|
||||
error.mapToPaymentAccountStatus()
|
||||
error.mapToPaymentAccountStatus(account.userWalletId)
|
||||
},
|
||||
ifRight = { customerInfo ->
|
||||
logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}")
|
||||
|
|
@ -158,7 +162,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
val customerInfo = onboardingRepository.getCustomerInfo(account.userWalletId).fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("proceedWithOrderId KYC check ${account.userWalletId} error: $error")
|
||||
return error.mapToPaymentAccountStatus()
|
||||
return error.mapToPaymentAccountStatus(account.userWalletId)
|
||||
},
|
||||
ifRight = { it },
|
||||
)
|
||||
|
|
@ -177,7 +181,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
return customerOrderRepository.getOrderData(userWalletId = account.userWalletId, orderId = orderId).fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("proceedWithOrderId ${account.userWalletId} orderId: $orderId error: $error")
|
||||
error.mapToPaymentAccountStatus()
|
||||
error.mapToPaymentAccountStatus(account.userWalletId)
|
||||
},
|
||||
ifRight = { orderData ->
|
||||
logger.i("proceedWithOrderId ${account.userWalletId}: $orderId status: ${orderData.status}")
|
||||
|
|
@ -246,7 +250,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
onboardingRepository.clearOrderId(account.userWalletId)
|
||||
return onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId)
|
||||
.fold(
|
||||
ifLeft = { it.mapToPaymentAccountStatus() },
|
||||
ifLeft = { it.mapToPaymentAccountStatus(account.userWalletId) },
|
||||
ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus(account.userWalletId) },
|
||||
)
|
||||
}
|
||||
|
|
@ -303,12 +307,22 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun VisaApiError.mapToPaymentAccountStatus(): PaymentAccountStatusValue {
|
||||
private suspend fun VisaApiError.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
|
||||
return when (this) {
|
||||
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced
|
||||
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatusValue.NotCreated
|
||||
is VisaApiError.Deactivated -> PaymentAccountStatusValue.NotCreated
|
||||
is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(userWalletId)
|
||||
is VisaApiError.Deactivated -> constructNotCreatedOrEmptyStatus(userWalletId)
|
||||
else -> PaymentAccountStatusValue.Error.Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun constructNotCreatedOrEmptyStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
|
||||
val entryPoint = TangemPayEntryPoint.BANNER
|
||||
val shouldShowBanner = !eligibilityManager.isPaeraCustomerForAnyWallet(entryPoint) &&
|
||||
eligibilityManager.getEligibleWallets(shouldExcludePaeraCustomers = false, entryPoint = entryPoint)
|
||||
.any { it.walletId == userWalletId } &&
|
||||
!onboardingRepository.getHideMainOnboardingBanner(userWalletId)
|
||||
|
||||
return if (shouldShowBanner) PaymentAccountStatusValue.NotCreated else PaymentAccountStatusValue.Empty
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import arrow.core.getOrElse
|
|||
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.datasource.api.pay.TangemPayApi
|
||||
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
|
||||
import com.tangem.datasource.api.pay.models.request.OrderRequest
|
||||
|
|
@ -17,6 +18,8 @@ import com.tangem.datasource.local.visa.TangemPayStorage
|
|||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.pay.TangemPayEligibilityType
|
||||
import com.tangem.domain.models.account.CardDisplayName
|
||||
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.kyc.KycStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
|
@ -48,6 +51,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
private val authDataSource: TangemPayAuthDataSource,
|
||||
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val paymentAccountStatusStore: PaymentAccountStatusesStore,
|
||||
) : OnboardingRepository {
|
||||
|
||||
// Save data for a session
|
||||
|
|
@ -285,6 +289,13 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
|
||||
override suspend fun setHideMainOnboardingBanner(userWalletId: UserWalletId) {
|
||||
tangemPayStorage.storeHideOnboardingBanner(userWalletId, hide = true)
|
||||
paymentAccountStatusStore.store(
|
||||
userWalletId = userWalletId,
|
||||
status = AccountStatus.Payment(
|
||||
account = Account.Payment(userWalletId),
|
||||
value = PaymentAccountStatusValue.Empty,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun disableTangemPay(userWalletId: UserWalletId): Either<VisaApiError, Unit> {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
|
|||
* Accounts mode is considered enabled if any [com.tangem.domain.account.models.AccountStatusList] produced for the
|
||||
* user's wallets has more than one [AccountStatus.CryptoPortfolio], or has a [AccountStatus.Payment] with any
|
||||
|
||||
* [PaymentAccountStatusValue.Empty].
|
||||
*
|
||||
* @property multiAccountStatusListSupplier supplier that provides a list of
|
||||
* [com.tangem.domain.account.models.AccountStatusList]s for all user wallets
|
||||
|
|
@ -45,6 +46,16 @@ class IsAccountsModeEnabledUseCase(
|
|||
}
|
||||
|
||||
private fun PaymentAccountStatusValue.isActivePayment(): Boolean {
|
||||
return this !is PaymentAccountStatusValue.NotCreated
|
||||
return when (this) {
|
||||
is PaymentAccountStatusValue.Empty,
|
||||
is PaymentAccountStatusValue.NotCreated,
|
||||
-> false
|
||||
is PaymentAccountStatusValue.Error,
|
||||
is PaymentAccountStatusValue.IssuingCard,
|
||||
is PaymentAccountStatusValue.Loaded,
|
||||
is PaymentAccountStatusValue.Loading,
|
||||
is PaymentAccountStatusValue.UnderReview,
|
||||
-> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,18 @@ class IsAccountsModeEnabledUseCaseTest {
|
|||
Truth.assertThat(actual).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns false when payment account is Empty`() = runTest {
|
||||
val statusList = createAccountStatusList(
|
||||
statuses = listOf(mockCryptoPortfolio(), mockPayment(PaymentAccountStatusValue.Empty)),
|
||||
)
|
||||
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
|
||||
|
||||
val actual = useCase.invoke().first()
|
||||
|
||||
Truth.assertThat(actual).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns true when payment account is UnderReview`() = runTest {
|
||||
val statusList = createAccountStatusList(
|
||||
|
|
@ -210,6 +222,18 @@ class IsAccountsModeEnabledUseCaseTest {
|
|||
Truth.assertThat(actual).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns false when payment account is Empty`() = runTest {
|
||||
val statusList = createAccountStatusList(
|
||||
statuses = listOf(mockCryptoPortfolio(), mockPayment(PaymentAccountStatusValue.Empty)),
|
||||
)
|
||||
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList)
|
||||
|
||||
val actual = useCase.invokeSync()
|
||||
|
||||
Truth.assertThat(actual).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns true when payment account is UnderReview`() = runTest {
|
||||
val statusList = createAccountStatusList(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ sealed class PaymentAccountStatusValue {
|
|||
get() = when (this) {
|
||||
is Error,
|
||||
is IssuingCard,
|
||||
is Empty,
|
||||
is NotCreated,
|
||||
is UnderReview,
|
||||
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
|
||||
|
|
@ -44,12 +45,19 @@ sealed class PaymentAccountStatusValue {
|
|||
is Loaded -> copy(source = source)
|
||||
is UnderReview -> copy(source = source)
|
||||
is Loading,
|
||||
is Empty,
|
||||
is NotCreated,
|
||||
is Error,
|
||||
-> this
|
||||
}
|
||||
}
|
||||
|
||||
/** Represents an empty payment account status when no specific state is available. */
|
||||
@Serializable
|
||||
data object Empty : PaymentAccountStatusValue() {
|
||||
override val source: StatusSource = StatusSource.ACTUAL
|
||||
}
|
||||
|
||||
/** Represents the Loading state of a payment account, typically while fetching its details. */
|
||||
@Serializable
|
||||
data object Loading : PaymentAccountStatusValue() {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ android {
|
|||
namespace = "com.tangem.features.approval.impl"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/** Feature */
|
||||
|
|
@ -56,4 +60,12 @@ dependencies {
|
|||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import com.tangem.features.approval.api.GiveApprovalComponent
|
|||
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
|
||||
import com.tangem.features.send.v2.api.entity.FeeItem
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -61,6 +62,7 @@ internal class GiveApprovalModel @Inject constructor(
|
|||
private val urlOpener: UrlOpener,
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
|
||||
) : Model(), FeeSelectorModelCallback {
|
||||
|
||||
private val params: GiveApprovalComponent.Params = paramsContainer.require()
|
||||
|
|
@ -111,7 +113,12 @@ internal class GiveApprovalModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun onChangeApproveType(approveType: ApproveType) {
|
||||
uiState.update { it.copy(approveType = approveType) }
|
||||
if (uiState.value.approveType == approveType) return
|
||||
uiState.update { it.copy(approveType = approveType, isApproveButtonEnabled = false) }
|
||||
modelScope.launch {
|
||||
feeSelectorReloadTrigger.triggerLoadingState()
|
||||
feeSelectorReloadTrigger.triggerUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
fun onOpenLearnMoreAboutApproveClick() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
package com.tangem.features.approval.impl.model
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetAllowanceInfoUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class GiveApprovalModelTest {
|
||||
|
||||
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk(relaxed = true)
|
||||
private val getAllowanceInfoUseCase: GetAllowanceInfoUseCase = mockk(relaxed = true)
|
||||
private val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true)
|
||||
private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
|
||||
private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase = mockk(relaxed = true)
|
||||
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
|
||||
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true)
|
||||
private val urlOpener: UrlOpener = mockk(relaxed = true)
|
||||
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true)
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF")
|
||||
private val userWallet: UserWallet.Hot = mockk(relaxed = true)
|
||||
|
||||
private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk {
|
||||
every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
}
|
||||
|
||||
private val params = GiveApprovalComponent.Params(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyStatus = cryptoCurrencyStatus,
|
||||
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
|
||||
amount = "10",
|
||||
spenderAddress = "0xSpender",
|
||||
amountFooter = TextReference.EMPTY,
|
||||
feeFooter = TextReference.EMPTY,
|
||||
callback = mockk(relaxed = true),
|
||||
)
|
||||
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
every { getUserWalletUseCase.invoke(userWalletId) } returns userWallet.right()
|
||||
}
|
||||
|
||||
private fun createModel(): GiveApprovalModel = GiveApprovalModel(
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
paramsContainer = MutableParamsContainer(params),
|
||||
createApprovalTransactionUseCase = createApprovalTransactionUseCase,
|
||||
getAllowanceInfoUseCase = getAllowanceInfoUseCase,
|
||||
sendTransactionUseCase = sendTransactionUseCase,
|
||||
getFeeUseCase = getFeeUseCase,
|
||||
getFeeForGaslessUseCase = getFeeForGaslessUseCase,
|
||||
getFeeForTokenUseCase = getFeeForTokenUseCase,
|
||||
createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase,
|
||||
urlOpener = urlOpener,
|
||||
getUserWalletUseCase = getUserWalletUseCase,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
feeSelectorReloadTrigger = feeSelectorReloadTrigger,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN approveType LIMITED WHEN onChangeApproveType THEN triggers fee reload and updates state`() = runTest {
|
||||
val model = createModel()
|
||||
|
||||
model.onChangeApproveType(ApproveType.UNLIMITED)
|
||||
|
||||
val state = model.uiState.value
|
||||
assertThat(state.approveType).isEqualTo(ApproveType.UNLIMITED)
|
||||
assertThat(state.isApproveButtonEnabled).isFalse()
|
||||
coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerLoadingState() }
|
||||
coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerUpdate() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same approveType WHEN onChangeApproveType THEN does not trigger fee reload`() = runTest {
|
||||
val model = createModel()
|
||||
|
||||
model.onChangeApproveType(ApproveType.LIMITED)
|
||||
|
||||
assertThat(model.uiState.value.approveType).isEqualTo(ApproveType.LIMITED)
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() }
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() }
|
||||
}
|
||||
}
|
||||
|
|
@ -120,7 +120,6 @@ internal class OnrampSuccessComponentModel @Inject constructor(
|
|||
expressTxStatusTaskScheduler.scheduleTask(
|
||||
modelScope,
|
||||
PeriodicTask(
|
||||
isDelayFirst = false,
|
||||
delay = EXPRESS_STATUS_UPDATE_DELAY,
|
||||
task = {
|
||||
runSuspendCatching {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ interface PromoBannersBlockComponent : ComposableContentComponent {
|
|||
val isInitiallyVisibleOnScreen: Boolean = true,
|
||||
)
|
||||
|
||||
enum class Placeholder {
|
||||
MAIN,
|
||||
FEED,
|
||||
enum class Placeholder(val value: String) {
|
||||
MAIN("main"),
|
||||
FEED("shtorka"),
|
||||
}
|
||||
|
||||
interface Factory : ComponentFactory<Params, PromoBannersBlockComponent>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import java.util.Locale
|
|||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
private typealias ShownBannerKey = Pair<String, Int>
|
||||
|
||||
@ModelScoped
|
||||
internal class PromoBannersBlockModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
|
|
@ -34,8 +36,8 @@ internal class PromoBannersBlockModel @Inject constructor(
|
|||
private val params = paramsContainer.require<PromoBannersBlockComponent.Params>()
|
||||
private val converter = PromoBannerDisplayToNotificationConverter()
|
||||
|
||||
private val placeholder: String = params.placeholder.name.lowercase()
|
||||
private val shownBannerIds: MutableSet<Int> = ConcurrentHashMap.newKeySet()
|
||||
private val placeholderName: String = params.placeholder.value
|
||||
private val shownBannerIds: MutableSet<ShownBannerKey> = ConcurrentHashMap.newKeySet()
|
||||
private var isVisibleOnScreen: Boolean = params.isInitiallyVisibleOnScreen
|
||||
private var wasCarouselScrolled = false
|
||||
private val savedDisplayIdByWalletId: MutableMap<String, Int> = mutableMapOf()
|
||||
|
|
@ -92,7 +94,7 @@ internal class PromoBannersBlockModel @Inject constructor(
|
|||
banners = bannerUMs,
|
||||
isVisibleOnScreen = isVisibleOnScreen,
|
||||
placeholder = params.placeholder,
|
||||
onBannerShown = ::onBannerShown,
|
||||
onBannerShown = { displayId -> onBannerShown(walletId, displayId) },
|
||||
onCarouselScrolled = ::onCarouselScrolled,
|
||||
onPageChanged = { displayId -> savedDisplayIdByWalletId[walletId] = displayId },
|
||||
)
|
||||
|
|
@ -101,21 +103,21 @@ internal class PromoBannersBlockModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun onBannerShown(displayId: Int) {
|
||||
if (shownBannerIds.add(displayId)) {
|
||||
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholder))
|
||||
private fun onBannerShown(walletId: String, displayId: Int) {
|
||||
if (shownBannerIds.add(walletId to displayId)) {
|
||||
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholderName))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCarouselScrolled(displayId: Int) {
|
||||
if (!wasCarouselScrolled) {
|
||||
wasCarouselScrolled = true
|
||||
analyticsEventHandler.send(PromoBannerAnalyticsEvent.CarouselScrolled(displayId, placeholder))
|
||||
analyticsEventHandler.send(PromoBannerAnalyticsEvent.CarouselScrolled(displayId, placeholderName))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onButtonClick(displayId: Int, deeplink: String?) {
|
||||
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholder))
|
||||
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName))
|
||||
deeplink?.let { deeplinkLauncher.launch(it) }
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +133,7 @@ internal class PromoBannersBlockModel @Inject constructor(
|
|||
)
|
||||
|
||||
private fun onBannerDismiss(walletId: String, displayId: Int) {
|
||||
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholder))
|
||||
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholderName))
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
banners = state.banners
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ internal class DefaultPromoBannersRepository(
|
|||
val banners = withContext(dispatchers.io) {
|
||||
tangemTechApi.getPromoBannerDisplays(
|
||||
walletId = walletId,
|
||||
placeholder = placeholder.toApiValue(),
|
||||
placeholder = placeholder.value,
|
||||
languageISOCode = languageISOCode,
|
||||
).getOrThrow()
|
||||
.items
|
||||
|
|
@ -71,9 +71,4 @@ internal class DefaultPromoBannersRepository(
|
|||
tangemTechApi.dismissPromoBannerDisplay(displayId, request).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Placeholder.toApiValue(): String = when (this) {
|
||||
Placeholder.MAIN -> "main"
|
||||
Placeholder.FEED -> "shtorka"
|
||||
}
|
||||
}
|
||||
|
|
@ -24,10 +24,11 @@ internal class FeeExtendedSelectorComponent @AssistedInject constructor(
|
|||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.uiState.collectAsStateWithLifecycle()
|
||||
val current = state ?: return
|
||||
|
||||
FeeExtendedSelectorContent(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
state = current,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ class FeeExtendedSelectorModel @Inject constructor(
|
|||
initAppCurrency()
|
||||
}
|
||||
|
||||
val uiState: StateFlow<FeeExtendedSelectorUM>
|
||||
field = MutableStateFlow<FeeExtendedSelectorUM>(getInitialState())
|
||||
val uiState: StateFlow<FeeExtendedSelectorUM?>
|
||||
field = MutableStateFlow<FeeExtendedSelectorUM?>(getInitialState())
|
||||
|
||||
init {
|
||||
params.state
|
||||
|
|
@ -44,8 +44,8 @@ class FeeExtendedSelectorModel @Inject constructor(
|
|||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun getInitialState(): FeeExtendedSelectorUM {
|
||||
val parentContentState = params.state.value as FeeSelectorUM.Content
|
||||
private fun getInitialState(): FeeExtendedSelectorUM? {
|
||||
val parentContentState = params.state.value as? FeeSelectorUM.Content ?: return null
|
||||
return convertState(parentContentState)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ internal class FeeTokenSelectorComponent @AssistedInject constructor(
|
|||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.uiState.collectAsStateWithLifecycle()
|
||||
val current = state ?: return
|
||||
|
||||
FeeTokenSelectorContent(
|
||||
state = state,
|
||||
state = current,
|
||||
intents = model,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ internal class FeeTokenSelectorModel @Inject constructor(
|
|||
private val params = paramsContainer.require<FeeSelectorComponentParams>()
|
||||
private var appCurrency = AppCurrency.Default
|
||||
|
||||
val uiState: StateFlow<FeeTokenSelectorUM>
|
||||
val uiState: StateFlow<FeeTokenSelectorUM?>
|
||||
field = MutableStateFlow(getInitialState())
|
||||
|
||||
init {
|
||||
|
|
@ -59,8 +59,8 @@ internal class FeeTokenSelectorModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun getInitialState(): FeeTokenSelectorUM {
|
||||
val parentState = params.state.value as FeeSelectorUM.Content
|
||||
private fun getInitialState(): FeeTokenSelectorUM? {
|
||||
val parentState = params.state.value as? FeeSelectorUM.Content ?: return null
|
||||
return stateFromParent(parentState)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ internal fun StakingConfirmationContent(
|
|||
)
|
||||
ValidatorBlock(
|
||||
validatorState = validatorState,
|
||||
isClickable = !isTransactionInProgress,
|
||||
isClickable = !isTransactionSent && !isTransactionInProgress,
|
||||
onClick = clickIntents::openValidators,
|
||||
)
|
||||
StakingFeeBlock(feeState = state.feeState)
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ internal fun StakingSuccessContent(
|
|||
)
|
||||
ValidatorBlock(
|
||||
validatorState = validatorState,
|
||||
isClickable = !isTransactionInProgress,
|
||||
isClickable = !isTransactionSent && !isTransactionInProgress,
|
||||
onClick = clickIntents::openValidators,
|
||||
)
|
||||
StakingFeeBlock(feeState = state.feeState)
|
||||
|
|
|
|||
|
|
@ -1,38 +1,61 @@
|
|||
package com.tangem.features.swap.v2.impl.amount.analytics
|
||||
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
|
||||
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents
|
||||
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages
|
||||
|
||||
internal class SwapAmountAnalyticsSender(
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
) {
|
||||
|
||||
private var lastSentErrorMessage: String? = null
|
||||
private var lastSentEvent: AnalyticsEvent? = null
|
||||
|
||||
fun sendErrorIfNeeded(quotes: List<SwapQuoteUM>, selectedQuote: SwapQuoteUM?) {
|
||||
val errorMessage = resolveErrorMessage(quotes, selectedQuote)
|
||||
if (errorMessage == lastSentErrorMessage) return
|
||||
lastSentErrorMessage = errorMessage
|
||||
if (errorMessage != null) {
|
||||
analyticsEventHandler.send(
|
||||
SendWithSwapAnalyticEvents.SendWithSwapError(
|
||||
errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Amount,
|
||||
message = errorMessage,
|
||||
),
|
||||
fun sendErrorIfNeeded(
|
||||
quotes: List<SwapQuoteUM>,
|
||||
selectedQuote: SwapQuoteUM?,
|
||||
fromToken: CryptoCurrency,
|
||||
toToken: CryptoCurrency,
|
||||
hasInsufficientBalance: Boolean,
|
||||
) {
|
||||
val event = resolveEvent(
|
||||
quotes = quotes,
|
||||
selectedQuote = selectedQuote,
|
||||
fromToken = fromToken,
|
||||
toToken = toToken,
|
||||
hasInsufficientBalance = hasInsufficientBalance,
|
||||
)
|
||||
if (event?.event == lastSentEvent?.event && event?.params == lastSentEvent?.params) return
|
||||
lastSentEvent = event
|
||||
if (event != null) {
|
||||
analyticsEventHandler.send(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveEvent(
|
||||
quotes: List<SwapQuoteUM>,
|
||||
selectedQuote: SwapQuoteUM?,
|
||||
fromToken: CryptoCurrency,
|
||||
toToken: CryptoCurrency,
|
||||
hasInsufficientBalance: Boolean,
|
||||
): AnalyticsEvent? {
|
||||
if (hasInsufficientBalance) {
|
||||
return SendWithSwapAnalyticEvents.ErrorInsufficientBalance(fromToken = fromToken)
|
||||
}
|
||||
if (quotes.isEmpty()) return null
|
||||
val error = (selectedQuote as? SwapQuoteUM.Error)?.expressError ?: return null
|
||||
return when (error) {
|
||||
is ExpressError.AmountError.TooSmallError ->
|
||||
SendWithSwapAnalyticEvents.ErrorMinAmount(fromToken = fromToken)
|
||||
is ExpressError.AmountError.TooBigError ->
|
||||
SendWithSwapAnalyticEvents.ErrorMaxAmount(fromToken = fromToken)
|
||||
else -> SendWithSwapAnalyticEvents.ErrorExpressQuote(
|
||||
fromToken = fromToken,
|
||||
toToken = toToken,
|
||||
errorDescription = "code=${error.code}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveErrorMessage(quotes: List<SwapQuoteUM>, selectedQuote: SwapQuoteUM?): String? {
|
||||
if (quotes.isEmpty()) return SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE_NO_PROVIDERS
|
||||
val error = (selectedQuote as? SwapQuoteUM.Error)?.expressError ?: return null
|
||||
return when (error) {
|
||||
is ExpressError.AmountError.TooSmallError -> SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT
|
||||
is ExpressError.AmountError.TooBigError -> SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT
|
||||
else -> "${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=${error.code}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,6 @@ import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
|
|||
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
|
||||
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents
|
||||
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents.NoticeFixedRate.toAnalyticsRateType
|
||||
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.Debouncer
|
||||
import com.tangem.utils.coroutines.PeriodicTask
|
||||
|
|
@ -152,11 +151,15 @@ internal class SwapAmountModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun onStart() {
|
||||
val isDelayFirst = params !is SwapAmountComponentParams.AmountBlockParams
|
||||
val initialDelay = if (params is SwapAmountComponentParams.AmountBlockParams) {
|
||||
BLOCK_INITIAL_QUOTE_DELAY
|
||||
} else {
|
||||
QUOTES_UPDATE_DELAY
|
||||
}
|
||||
configAmountNavigation()
|
||||
quoteTaskScheduler.scheduleTask(
|
||||
scope = modelScope,
|
||||
task = loadQuotesTask(isDelayFirst = isDelayFirst),
|
||||
task = loadQuotesTask(initialDelay = initialDelay),
|
||||
)
|
||||
subscribeOnAutoupdateEnabling()
|
||||
}
|
||||
|
|
@ -618,18 +621,35 @@ internal class SwapAmountModel @Inject constructor(
|
|||
| Secondary -> $secondaryStatus
|
||||
""".trimIndent(),
|
||||
)
|
||||
analyticsEventHandler.send(
|
||||
SendWithSwapAnalyticEvents.SendWithSwapError(
|
||||
errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Amount,
|
||||
message = "${SendWithSwapAnalyticsErrorMessages.INVALID_CRYPTOCURRENCIES_STATUS}: " +
|
||||
"primary=$primaryStatus, secondary=$secondaryStatus",
|
||||
),
|
||||
)
|
||||
showErrorAlert(errorMessage = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendAmountErrorAnalyticsIfNeeded(quotes: List<SwapQuoteUM>) {
|
||||
val content = uiState.value as? SwapAmountUM.Content ?: return
|
||||
val toCurrency = content.secondaryCryptoCurrencyStatus?.currency ?: return
|
||||
amountAnalyticsSender.sendErrorIfNeeded(
|
||||
quotes = quotes,
|
||||
selectedQuote = content.selectedQuote,
|
||||
fromToken = content.primaryCryptoCurrencyStatus.currency,
|
||||
toToken = toCurrency,
|
||||
hasInsufficientBalance = hasInsufficientBalance(content),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasInsufficientBalance(content: SwapAmountUM.Content): Boolean {
|
||||
val primaryBalance = content.primaryCryptoCurrencyStatus.value.amount ?: return false
|
||||
val fromAmount = when (content.selectedAmountType) {
|
||||
SwapAmountType.To -> (content.selectedQuote as? SwapQuoteUM.Content)?.fromAmount
|
||||
SwapAmountType.From -> {
|
||||
val field = content.primaryAmount as? SwapAmountFieldUM.Content
|
||||
(field?.amountField as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
|
||||
}
|
||||
} ?: return false
|
||||
return fromAmount > primaryBalance
|
||||
}
|
||||
|
||||
private fun sendAmountScreenOpenedIfNeeded(secondaryStatus: CryptoCurrencyStatus) {
|
||||
if (params !is SwapAmountComponentParams.AmountParams) return
|
||||
if (isAmountScreenOpenedSent) return
|
||||
|
|
@ -713,7 +733,9 @@ internal class SwapAmountModel @Inject constructor(
|
|||
val isAmountScreen = params is SwapAmountComponentParams.AmountParams
|
||||
val isAmountError = amountField?.amountTextField?.isError == true || amountValue.isNullOrZero()
|
||||
if (isAmountScreen && isAmountError) {
|
||||
uiState.transformerUpdate(SwapQuoteEmptyStateTransformer); return
|
||||
uiState.transformerUpdate(SwapQuoteEmptyStateTransformer)
|
||||
sendAmountErrorAnalyticsIfNeeded(quotes = emptyList())
|
||||
return
|
||||
}
|
||||
|
||||
val rateType = when (state.selectedAmountType) {
|
||||
|
|
@ -785,8 +807,7 @@ internal class SwapAmountModel @Inject constructor(
|
|||
),
|
||||
)
|
||||
if (params is SwapAmountComponentParams.AmountParams) {
|
||||
val selectedQuote = (uiState.value as? SwapAmountUM.Content)?.selectedQuote
|
||||
amountAnalyticsSender.sendErrorIfNeeded(quotes, selectedQuote)
|
||||
sendAmountErrorAnalyticsIfNeeded(quotes)
|
||||
}
|
||||
feeSelectorReloadTrigger.triggerUpdate()
|
||||
}
|
||||
|
|
@ -835,10 +856,10 @@ internal class SwapAmountModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun loadQuotesTask(isDelayFirst: Boolean = true): PeriodicTask<Unit> {
|
||||
private fun loadQuotesTask(initialDelay: Long = QUOTES_UPDATE_DELAY): PeriodicTask<Unit> {
|
||||
return PeriodicTask(
|
||||
delay = QUOTES_UPDATE_DELAY,
|
||||
isDelayFirst = isDelayFirst,
|
||||
initialDelay = initialDelay,
|
||||
task = {
|
||||
runCatching { loadQuotes(isSilentReload = true) }
|
||||
},
|
||||
|
|
@ -919,5 +940,6 @@ internal class SwapAmountModel @Inject constructor(
|
|||
private companion object {
|
||||
const val DEBOUNCE_AMOUNT_DELAY = 500L
|
||||
const val QUOTES_UPDATE_DELAY = 10000L
|
||||
const val BLOCK_INITIAL_QUOTE_DELAY = 1000L
|
||||
}
|
||||
}
|
||||
|
|
@ -61,9 +61,12 @@ internal class SwapAmountSelectQuoteTransformer(
|
|||
secondaryCryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus,
|
||||
)
|
||||
|
||||
val hasInsufficientFundsForFixed = hasInsufficientFundsForFixed(prevState, quoteContent)
|
||||
|
||||
return prevState.copy(
|
||||
isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content &&
|
||||
!hasInsufficientBalance(prevState, quoteContent, newPrimaryAmount),
|
||||
!hasInsufficientBalance(prevState, quoteContent, newPrimaryAmount) &&
|
||||
!hasInsufficientFundsForFixed,
|
||||
selectedQuote = quoteUM,
|
||||
isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true,
|
||||
primaryAmount = newPrimaryAmount,
|
||||
|
|
@ -72,6 +75,16 @@ internal class SwapAmountSelectQuoteTransformer(
|
|||
)
|
||||
}
|
||||
|
||||
private fun hasInsufficientFundsForFixed(
|
||||
prevState: SwapAmountUM.Content,
|
||||
quoteContent: SwapQuoteUM.Content?,
|
||||
): Boolean {
|
||||
if (prevState.selectedAmountType != SwapAmountType.To) return false
|
||||
val fromAmount = quoteContent?.fromAmount ?: return false
|
||||
val primaryBalance = prevState.primaryCryptoCurrencyStatus.value.amount ?: return false
|
||||
return fromAmount > primaryBalance
|
||||
}
|
||||
|
||||
private fun getPrimaryAmount(
|
||||
prevState: SwapAmountUM.Content,
|
||||
quoteContent: SwapQuoteUM.Content?,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.tangem.core.decompose.context.AppComponentContext
|
|||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressRateType
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
|
@ -53,6 +54,7 @@ internal class SwapNotificationsComponent(
|
|||
val fromCryptoCurrencyStatus: CryptoCurrencyStatus? = null,
|
||||
val priceImpact: PriceImpact? = null,
|
||||
val provider: ExpressProvider? = null,
|
||||
val rateType: ExpressRateType? = null,
|
||||
val shouldIncludeFeeInBalanceCheck: Boolean = false,
|
||||
val feeValue: BigDecimal? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.tangem.core.decompose.model.ParamsContainer
|
|||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.express.models.ExpressRateType
|
||||
import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase
|
||||
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
|
||||
import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact
|
||||
|
|
@ -17,7 +18,6 @@ import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent
|
|||
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsUpdateListener
|
||||
import com.tangem.features.swap.v2.impl.notifications.entity.SwapNotificationUM
|
||||
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents
|
||||
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -45,7 +45,7 @@ internal class SwapNotificationsModel @Inject constructor(
|
|||
private val params: SwapNotificationsComponent.Params = paramsContainer.require()
|
||||
|
||||
private var notificationData = params.swapNotificationData
|
||||
private var lastSentErrorMessages: Set<String> = emptySet()
|
||||
private var lastSentErrorKeys: Set<Pair<String, Map<String, String>>> = emptySet()
|
||||
|
||||
val uiState: StateFlow<ImmutableList<NotificationUM>>
|
||||
field = MutableStateFlow<ImmutableList<NotificationUM>>(persistentListOf())
|
||||
|
|
@ -150,21 +150,28 @@ internal class SwapNotificationsModel @Inject constructor(
|
|||
fun MutableList<NotificationUM>.addExpressErrorNotification() {
|
||||
val expressError = notificationData.expressError ?: return
|
||||
val fromCryptoCurrency = notificationData.fromCryptoCurrency ?: return
|
||||
val toCryptoCurrency = notificationData.toCryptoCurrencyStatus?.currency ?: return
|
||||
|
||||
val amountErrorCurrency = if (notificationData.rateType == ExpressRateType.Fixed) {
|
||||
toCryptoCurrency
|
||||
} else {
|
||||
fromCryptoCurrency
|
||||
}
|
||||
|
||||
val errorNotification = when (expressError) {
|
||||
is ExpressError.AmountError.TooSmallError -> SwapNotificationUM.Error.MinimalAmountError(
|
||||
expressError.amount.format {
|
||||
crypto(
|
||||
symbol = fromCryptoCurrency.symbol,
|
||||
decimals = fromCryptoCurrency.decimals,
|
||||
symbol = amountErrorCurrency.symbol,
|
||||
decimals = amountErrorCurrency.decimals,
|
||||
)
|
||||
},
|
||||
)
|
||||
is ExpressError.AmountError.TooBigError -> SwapNotificationUM.Error.MaximumAmountError(
|
||||
expressError.amount.format {
|
||||
crypto(
|
||||
symbol = fromCryptoCurrency.symbol,
|
||||
decimals = fromCryptoCurrency.decimals,
|
||||
symbol = amountErrorCurrency.symbol,
|
||||
decimals = amountErrorCurrency.decimals,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -195,32 +202,32 @@ internal class SwapNotificationsModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun sendErrorAnalyticsIfNeeded(notifications: List<NotificationUM>) {
|
||||
val currentErrors = notifications.mapNotNull { notification ->
|
||||
val fromToken = notificationData.fromCryptoCurrency ?: return
|
||||
val toToken = notificationData.toCryptoCurrencyStatus?.currency
|
||||
|
||||
val events = notifications.mapNotNull { notification ->
|
||||
when (notification) {
|
||||
is SwapNotificationUM.Error.InsufficientFunds ->
|
||||
SendWithSwapAnalyticsErrorMessages.INSUFFICIENT_BALANCE
|
||||
SendWithSwapAnalyticEvents.ErrorInsufficientBalance(fromToken = fromToken)
|
||||
is SwapNotificationUM.Error.MinimalAmountError ->
|
||||
SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT
|
||||
SendWithSwapAnalyticEvents.ErrorMinAmount(fromToken = fromToken)
|
||||
is SwapNotificationUM.Error.MaximumAmountError ->
|
||||
SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT
|
||||
is SwapNotificationUM.Warning.ExpressGeneralError ->
|
||||
"${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=${notification.expressError.code}"
|
||||
is NotificationUM.Error.DestinationTagRequired ->
|
||||
SendWithSwapAnalyticsErrorMessages.DESTINATION_TAG_REQUIRED
|
||||
SendWithSwapAnalyticEvents.ErrorMaxAmount(fromToken = fromToken)
|
||||
is SwapNotificationUM.Warning.ExpressGeneralError -> toToken?.let { receiveToken ->
|
||||
SendWithSwapAnalyticEvents.ErrorExpressQuote(
|
||||
fromToken = fromToken,
|
||||
toToken = receiveToken,
|
||||
errorDescription = "code=${notification.expressError.code}",
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.toSet()
|
||||
|
||||
val newErrors = currentErrors - lastSentErrorMessages
|
||||
lastSentErrorMessages = currentErrors
|
||||
|
||||
newErrors.forEach { errorMessage ->
|
||||
analyticsEventHandler.send(
|
||||
SendWithSwapAnalyticEvents.SendWithSwapError(
|
||||
errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Confirm,
|
||||
message = errorMessage,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val currentKeys = events.map { it.event to it.params }.toSet()
|
||||
val newEvents = events.filter { it.event to it.params !in lastSentErrorKeys }
|
||||
lastSentErrorKeys = currentKeys
|
||||
|
||||
newEvents.forEach(analyticsEventHandler::send)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent
|
|||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_DESCRIPTION
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.RATE_TYPE
|
||||
|
|
@ -120,19 +120,51 @@ internal sealed class SendWithSwapAnalyticEvents(
|
|||
params = emptyMap(),
|
||||
)
|
||||
|
||||
data class SendWithSwapError(
|
||||
val errorScreen: ErrorScreen,
|
||||
val message: String,
|
||||
data class ErrorInsufficientBalance(
|
||||
val fromToken: CryptoCurrency,
|
||||
) : SendWithSwapAnalyticEvents(
|
||||
event = when (errorScreen) {
|
||||
ErrorScreen.Amount -> "Send With Swap Amount Screen Error"
|
||||
ErrorScreen.Confirm -> "Send With Swap Confirm Screen Error"
|
||||
},
|
||||
event = "Error - Insufficient balance",
|
||||
params = mapOf(
|
||||
ERROR_MESSAGE to message,
|
||||
SEND_TOKEN to fromToken.symbol,
|
||||
SEND_BLOCKCHAIN to fromToken.network.name,
|
||||
),
|
||||
)
|
||||
|
||||
data class ErrorMinAmount(
|
||||
val fromToken: CryptoCurrency,
|
||||
) : SendWithSwapAnalyticEvents(
|
||||
event = "Error - Min amount",
|
||||
params = mapOf(
|
||||
SEND_TOKEN to fromToken.symbol,
|
||||
SEND_BLOCKCHAIN to fromToken.network.name,
|
||||
),
|
||||
)
|
||||
|
||||
data class ErrorMaxAmount(
|
||||
val fromToken: CryptoCurrency,
|
||||
) : SendWithSwapAnalyticEvents(
|
||||
event = "Error - Max amount",
|
||||
params = mapOf(
|
||||
SEND_TOKEN to fromToken.symbol,
|
||||
SEND_BLOCKCHAIN to fromToken.network.name,
|
||||
),
|
||||
)
|
||||
|
||||
data class ErrorExpressQuote(
|
||||
val fromToken: CryptoCurrency,
|
||||
val toToken: CryptoCurrency,
|
||||
val errorDescription: String? = null,
|
||||
) : SendWithSwapAnalyticEvents(
|
||||
event = "Error - Express quote",
|
||||
params = buildMap {
|
||||
put(SEND_TOKEN, fromToken.symbol)
|
||||
put(SEND_BLOCKCHAIN, fromToken.network.name)
|
||||
put(RECEIVE_TOKEN, toToken.symbol)
|
||||
put(RECEIVE_BLOCKCHAIN, toToken.network.name)
|
||||
if (errorDescription != null) put(ERROR_DESCRIPTION, errorDescription)
|
||||
},
|
||||
)
|
||||
|
||||
class HighPriceImpact(
|
||||
val sendToken: String,
|
||||
val receiveToken: String,
|
||||
|
|
@ -168,11 +200,6 @@ internal sealed class SendWithSwapAnalyticEvents(
|
|||
),
|
||||
)
|
||||
|
||||
enum class ErrorScreen {
|
||||
Amount,
|
||||
Confirm,
|
||||
}
|
||||
|
||||
enum class RateType {
|
||||
Float,
|
||||
Fixed,
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
package com.tangem.features.swap.v2.impl.sendviaswap.analytics
|
||||
|
||||
internal object SendWithSwapAnalyticsErrorMessages {
|
||||
const val INSUFFICIENT_BALANCE = "Error - Insufficient balance"
|
||||
const val MIN_AMOUNT = "Error - Min amount"
|
||||
const val MAX_AMOUNT = "Error - Max amount"
|
||||
const val EXPRESS_QUOTE_NO_PROVIDERS = "Error - Express quote no providers found"
|
||||
const val EXPRESS_QUOTE = "Error - Express quote"
|
||||
const val DESTINATION_TAG_REQUIRED = "Error - Destination tag required"
|
||||
const val INVALID_CRYPTOCURRENCIES_STATUS = "Error - Invalid cryptocurrencies status"
|
||||
}
|
||||
|
|
@ -141,6 +141,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
|
|||
enteredFromAmount = model.confirmData.enteredFromAmount,
|
||||
fromCryptoCurrencyStatus = model.confirmData.fromCryptoCurrencyStatus,
|
||||
priceImpact = model.confirmData.priceImpact,
|
||||
rateType = model.confirmData.rateType,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -320,12 +320,17 @@ internal class SendWithSwapConfirmModel @Inject constructor(
|
|||
isAmountSubtractAvailable = isAmountSubtractAvailable,
|
||||
onExpressError = { expressError ->
|
||||
uiState.transformerUpdate(SendWithSwapConfirmSendingStateTransformer(false))
|
||||
analyticsEventHandler.send(
|
||||
SendWithSwapAnalyticEvents.SendWithSwapError(
|
||||
errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Confirm,
|
||||
message = "Express error: $expressError",
|
||||
),
|
||||
)
|
||||
val fromCurrency = confirmData.fromCryptoCurrencyStatus?.currency
|
||||
val toCurrency = confirmData.toCryptoCurrencyStatus?.currency
|
||||
if (fromCurrency != null && toCurrency != null) {
|
||||
analyticsEventHandler.send(
|
||||
SendWithSwapAnalyticEvents.ErrorExpressQuote(
|
||||
fromToken = fromCurrency,
|
||||
toToken = toCurrency,
|
||||
errorDescription = "code=${expressError.code}",
|
||||
),
|
||||
)
|
||||
}
|
||||
swapAlertFactory.getGenericErrorState(
|
||||
expressError = expressError,
|
||||
onFailedTxEmailClick = {
|
||||
|
|
@ -343,12 +348,6 @@ internal class SendWithSwapConfirmModel @Inject constructor(
|
|||
},
|
||||
onSendError = { error ->
|
||||
uiState.transformerUpdate(SendWithSwapConfirmSendingStateTransformer(false))
|
||||
analyticsEventHandler.send(
|
||||
SendWithSwapAnalyticEvents.SendWithSwapError(
|
||||
errorScreen = SendWithSwapAnalyticEvents.ErrorScreen.Confirm,
|
||||
message = "Send error: ${error?.toString().orEmpty()}",
|
||||
),
|
||||
)
|
||||
swapAlertFactory.getSendTransactionErrorState(
|
||||
error = error,
|
||||
onFailedTxEmailClick = { _ ->
|
||||
|
|
@ -461,6 +460,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
|
|||
fromCryptoCurrencyStatus = confirmData.fromCryptoCurrencyStatus,
|
||||
priceImpact = confirmData.priceImpact,
|
||||
provider = confirmData.quote?.provider,
|
||||
rateType = confirmData.rateType,
|
||||
shouldIncludeFeeInBalanceCheck = isFixedRate && isAmountSubtractAvailable,
|
||||
feeValue = confirmData.fee?.amount?.value,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import com.tangem.core.analytics.models.AnalyticsEvent
|
|||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressProviderType
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
|
||||
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents
|
||||
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticsErrorMessages
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
|
|
@ -21,6 +22,21 @@ class SwapAmountAnalyticsSenderTest {
|
|||
private val analyticsEventHandler = mockk<AnalyticsEventHandler>(relaxed = true)
|
||||
private val sender = SwapAmountAnalyticsSender(analyticsEventHandler)
|
||||
|
||||
private val fromNetwork = mockk<Network>(relaxed = true).also {
|
||||
every { it.name } returns "Ethereum"
|
||||
}
|
||||
private val toNetwork = mockk<Network>(relaxed = true).also {
|
||||
every { it.name } returns "Bitcoin"
|
||||
}
|
||||
private val fromToken = mockk<CryptoCurrency>(relaxed = true).also {
|
||||
every { it.symbol } returns "ETH"
|
||||
every { it.network } returns fromNetwork
|
||||
}
|
||||
private val toToken = mockk<CryptoCurrency>(relaxed = true).also {
|
||||
every { it.symbol } returns "BTC"
|
||||
every { it.network } returns toNetwork
|
||||
}
|
||||
|
||||
private val testProvider = ExpressProvider(
|
||||
providerId = "test",
|
||||
name = "Test Provider",
|
||||
|
|
@ -31,21 +47,41 @@ class SwapAmountAnalyticsSenderTest {
|
|||
slippage = null,
|
||||
)
|
||||
|
||||
private fun send(
|
||||
quotes: List<SwapQuoteUM> = emptyList(),
|
||||
selectedQuote: SwapQuoteUM? = null,
|
||||
hasInsufficientBalance: Boolean = false,
|
||||
) = sender.sendErrorIfNeeded(
|
||||
quotes = quotes,
|
||||
selectedQuote = selectedQuote,
|
||||
fromToken = fromToken,
|
||||
toToken = toToken,
|
||||
hasInsufficientBalance = hasInsufficientBalance,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty quotes WHEN sendErrorIfNeeded THEN send no providers error`() {
|
||||
val eventSlot = slot<AnalyticsEvent>()
|
||||
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
|
||||
fun `GIVEN empty quotes WHEN sendErrorIfNeeded THEN do not send analytics`() {
|
||||
send(quotes = emptyList(), selectedQuote = null)
|
||||
|
||||
sender.sendErrorIfNeeded(quotes = emptyList(), selectedQuote = null)
|
||||
|
||||
verify(exactly = 1) { analyticsEventHandler.send(any()) }
|
||||
val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError
|
||||
assertThat(event.errorScreen).isEqualTo(SendWithSwapAnalyticEvents.ErrorScreen.Amount)
|
||||
assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE_NO_PROVIDERS)
|
||||
verify(exactly = 0) { analyticsEventHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN too small error quote WHEN sendErrorIfNeeded THEN send min amount error`() {
|
||||
fun `GIVEN insufficient balance WHEN sendErrorIfNeeded THEN send ErrorInsufficientBalance with from token params`() {
|
||||
val eventSlot = slot<AnalyticsEvent>()
|
||||
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
|
||||
|
||||
send(hasInsufficientBalance = true)
|
||||
|
||||
verify(exactly = 1) { analyticsEventHandler.send(any()) }
|
||||
val event = eventSlot.captured
|
||||
assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorInsufficientBalance::class.java)
|
||||
assertThat(event.event).isEqualTo("Error - Insufficient balance")
|
||||
assertThat(event.params).containsExactly("Send Token", "ETH", "Send Blockchain", "Ethereum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN insufficient balance and express error WHEN sendErrorIfNeeded THEN insufficient balance takes priority`() {
|
||||
val errorQuote = SwapQuoteUM.Error(
|
||||
provider = testProvider,
|
||||
expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")),
|
||||
|
|
@ -53,15 +89,32 @@ class SwapAmountAnalyticsSenderTest {
|
|||
val eventSlot = slot<AnalyticsEvent>()
|
||||
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
|
||||
|
||||
sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
send(quotes = listOf(errorQuote), selectedQuote = errorQuote, hasInsufficientBalance = true)
|
||||
|
||||
verify(exactly = 1) { analyticsEventHandler.send(any()) }
|
||||
val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError
|
||||
assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.MIN_AMOUNT)
|
||||
assertThat(eventSlot.captured).isInstanceOf(SendWithSwapAnalyticEvents.ErrorInsufficientBalance::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN too big error quote WHEN sendErrorIfNeeded THEN send max amount error`() {
|
||||
fun `GIVEN too small error quote WHEN sendErrorIfNeeded THEN send ErrorMinAmount with from token params`() {
|
||||
val errorQuote = SwapQuoteUM.Error(
|
||||
provider = testProvider,
|
||||
expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")),
|
||||
)
|
||||
val eventSlot = slot<AnalyticsEvent>()
|
||||
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
|
||||
|
||||
send(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
|
||||
verify(exactly = 1) { analyticsEventHandler.send(any()) }
|
||||
val event = eventSlot.captured
|
||||
assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorMinAmount::class.java)
|
||||
assertThat(event.event).isEqualTo("Error - Min amount")
|
||||
assertThat(event.params).containsExactly("Send Token", "ETH", "Send Blockchain", "Ethereum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN too big error quote WHEN sendErrorIfNeeded THEN send ErrorMaxAmount with from token params`() {
|
||||
val errorQuote = SwapQuoteUM.Error(
|
||||
provider = testProvider,
|
||||
expressError = ExpressError.AmountError.TooBigError(code = 1002, amount = BigDecimal("1000")),
|
||||
|
|
@ -69,15 +122,17 @@ class SwapAmountAnalyticsSenderTest {
|
|||
val eventSlot = slot<AnalyticsEvent>()
|
||||
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
|
||||
|
||||
sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
send(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
|
||||
verify(exactly = 1) { analyticsEventHandler.send(any()) }
|
||||
val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError
|
||||
assertThat(event.message).isEqualTo(SendWithSwapAnalyticsErrorMessages.MAX_AMOUNT)
|
||||
val event = eventSlot.captured
|
||||
assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorMaxAmount::class.java)
|
||||
assertThat(event.event).isEqualTo("Error - Max amount")
|
||||
assertThat(event.params).containsExactly("Send Token", "ETH", "Send Blockchain", "Ethereum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unknown express error WHEN sendErrorIfNeeded THEN send express quote error with code`() {
|
||||
fun `GIVEN unknown express error WHEN sendErrorIfNeeded THEN send ErrorExpressQuote with both tokens and code`() {
|
||||
val errorQuote = SwapQuoteUM.Error(
|
||||
provider = testProvider,
|
||||
expressError = ExpressError.InternalError(code = 500),
|
||||
|
|
@ -85,18 +140,26 @@ class SwapAmountAnalyticsSenderTest {
|
|||
val eventSlot = slot<AnalyticsEvent>()
|
||||
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
|
||||
|
||||
sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
send(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
|
||||
verify(exactly = 1) { analyticsEventHandler.send(any()) }
|
||||
val event = eventSlot.captured as SendWithSwapAnalyticEvents.SendWithSwapError
|
||||
assertThat(event.message).isEqualTo("${SendWithSwapAnalyticsErrorMessages.EXPRESS_QUOTE}: code=500")
|
||||
val event = eventSlot.captured
|
||||
assertThat(event).isInstanceOf(SendWithSwapAnalyticEvents.ErrorExpressQuote::class.java)
|
||||
assertThat(event.event).isEqualTo("Error - Express quote")
|
||||
assertThat(event.params).containsExactly(
|
||||
"Send Token", "ETH",
|
||||
"Send Blockchain", "Ethereum",
|
||||
"Receive Token", "BTC",
|
||||
"Receive Blockchain", "Bitcoin",
|
||||
"Error Description", "code=500",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content quote WHEN sendErrorIfNeeded THEN do not send analytics`() {
|
||||
val contentQuote = mockk<SwapQuoteUM.Content>()
|
||||
|
||||
sender.sendErrorIfNeeded(quotes = listOf(contentQuote), selectedQuote = contentQuote)
|
||||
send(quotes = listOf(contentQuote), selectedQuote = contentQuote)
|
||||
|
||||
verify(exactly = 0) { analyticsEventHandler.send(any()) }
|
||||
}
|
||||
|
|
@ -108,8 +171,16 @@ class SwapAmountAnalyticsSenderTest {
|
|||
expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")),
|
||||
)
|
||||
|
||||
sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
send(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
send(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
|
||||
verify(exactly = 1) { analyticsEventHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN insufficient balance twice WHEN sendErrorIfNeeded THEN send analytics only once`() {
|
||||
send(hasInsufficientBalance = true)
|
||||
send(hasInsufficientBalance = true)
|
||||
|
||||
verify(exactly = 1) { analyticsEventHandler.send(any()) }
|
||||
}
|
||||
|
|
@ -125,8 +196,21 @@ class SwapAmountAnalyticsSenderTest {
|
|||
expressError = ExpressError.AmountError.TooBigError(code = 1002, amount = BigDecimal("1000")),
|
||||
)
|
||||
|
||||
sender.sendErrorIfNeeded(quotes = listOf(smallError), selectedQuote = smallError)
|
||||
sender.sendErrorIfNeeded(quotes = listOf(bigError), selectedQuote = bigError)
|
||||
send(quotes = listOf(smallError), selectedQuote = smallError)
|
||||
send(quotes = listOf(bigError), selectedQuote = bigError)
|
||||
|
||||
verify(exactly = 2) { analyticsEventHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN express error then insufficient balance WHEN sendErrorIfNeeded THEN send analytics twice`() {
|
||||
val smallError = SwapQuoteUM.Error(
|
||||
provider = testProvider,
|
||||
expressError = ExpressError.AmountError.TooSmallError(code = 1001, amount = BigDecimal("0.01")),
|
||||
)
|
||||
|
||||
send(quotes = listOf(smallError), selectedQuote = smallError)
|
||||
send(quotes = listOf(smallError), selectedQuote = smallError, hasInsufficientBalance = true)
|
||||
|
||||
verify(exactly = 2) { analyticsEventHandler.send(any()) }
|
||||
}
|
||||
|
|
@ -139,9 +223,9 @@ class SwapAmountAnalyticsSenderTest {
|
|||
)
|
||||
val contentQuote = mockk<SwapQuoteUM.Content>()
|
||||
|
||||
sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
sender.sendErrorIfNeeded(quotes = listOf(contentQuote), selectedQuote = contentQuote)
|
||||
sender.sendErrorIfNeeded(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
send(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
send(quotes = listOf(contentQuote), selectedQuote = contentQuote)
|
||||
send(quotes = listOf(errorQuote), selectedQuote = errorQuote)
|
||||
|
||||
verify(exactly = 2) { analyticsEventHandler.send(any()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
package com.tangem.features.swap.v2.impl.amount.model.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressProviderType
|
||||
import com.tangem.domain.express.models.ExpressRateType
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.swap.models.SwapAmountType
|
||||
import com.tangem.domain.swap.models.SwapCurrencies
|
||||
import com.tangem.domain.swap.models.SwapDirection
|
||||
import com.tangem.domain.swap.models.SwapRateMode
|
||||
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
|
||||
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
|
||||
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class SwapAmountSelectQuoteTransformerTest {
|
||||
|
||||
private val provider = ExpressProvider(
|
||||
providerId = "test-provider",
|
||||
name = "Test Provider",
|
||||
type = ExpressProviderType.CEX,
|
||||
imageLarge = "",
|
||||
termsOfUse = null,
|
||||
privacyPolicy = null,
|
||||
slippage = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN fixed mode quote with fromAmount exceeding primary balance WHEN transform THEN isPrimaryButtonEnabled is false`() {
|
||||
// GIVEN
|
||||
val prevState = buildContentState(
|
||||
selectedAmountType = SwapAmountType.To,
|
||||
primaryBalance = BigDecimal("10"),
|
||||
)
|
||||
val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1"))
|
||||
val transformer = buildTransformer(quoteUM = quote)
|
||||
|
||||
// WHEN
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
// THEN
|
||||
val content = result as SwapAmountUM.Content
|
||||
assertThat(content.isPrimaryButtonEnabled).isFalse()
|
||||
assertThat(content.selectedQuote).isEqualTo(quote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fixed mode quote with fromAmount within primary balance WHEN transform THEN isPrimaryButtonEnabled is true`() {
|
||||
// GIVEN
|
||||
val prevState = buildContentState(
|
||||
selectedAmountType = SwapAmountType.To,
|
||||
primaryBalance = BigDecimal("100"),
|
||||
)
|
||||
val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1"))
|
||||
val transformer = buildTransformer(quoteUM = quote)
|
||||
|
||||
// WHEN
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
// THEN
|
||||
val content = result as SwapAmountUM.Content
|
||||
assertThat(content.isPrimaryButtonEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN float mode with selectedAmountType From WHEN transform THEN isPrimaryButtonEnabled is true regardless of fromAmount`() {
|
||||
// GIVEN
|
||||
val prevState = buildContentState(
|
||||
selectedAmountType = SwapAmountType.From,
|
||||
primaryBalance = BigDecimal("10"),
|
||||
)
|
||||
// fromAmount > balance, but we're in From-mode so the check must not fire
|
||||
val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1"))
|
||||
val transformer = buildTransformer(quoteUM = quote)
|
||||
|
||||
// WHEN
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
// THEN
|
||||
val content = result as SwapAmountUM.Content
|
||||
assertThat(content.isPrimaryButtonEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN quote is SwapQuoteUM Error WHEN transform THEN isPrimaryButtonEnabled is false`() {
|
||||
// GIVEN
|
||||
val prevState = buildContentState(
|
||||
selectedAmountType = SwapAmountType.To,
|
||||
primaryBalance = BigDecimal("100"),
|
||||
)
|
||||
val errorQuote = SwapQuoteUM.Error(
|
||||
provider = provider,
|
||||
expressError = ExpressError.InternalError(code = 500),
|
||||
)
|
||||
val transformer = buildTransformer(quoteUM = errorQuote)
|
||||
|
||||
// WHEN
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
// THEN
|
||||
val content = result as SwapAmountUM.Content
|
||||
assertThat(content.isPrimaryButtonEnabled).isFalse()
|
||||
assertThat(content.selectedQuote).isEqualTo(errorQuote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN prevState is SwapAmountUM Empty WHEN transform THEN returns the same state`() {
|
||||
// GIVEN
|
||||
val prevState = SwapAmountUM.Empty(swapDirection = SwapDirection.Direct)
|
||||
val quote = buildContentQuote(fromAmount = BigDecimal("20"), toAmount = BigDecimal("1"))
|
||||
val transformer = buildTransformer(quoteUM = quote)
|
||||
|
||||
// WHEN
|
||||
val result = transformer.transform(prevState)
|
||||
|
||||
// THEN
|
||||
assertThat(result).isEqualTo(prevState)
|
||||
}
|
||||
|
||||
private fun buildTransformer(quoteUM: SwapQuoteUM): SwapAmountSelectQuoteTransformer {
|
||||
return SwapAmountSelectQuoteTransformer(
|
||||
quoteUM = quoteUM,
|
||||
secondaryMaximumAmountBoundary = null,
|
||||
secondaryMinimumAmountBoundary = null,
|
||||
isNeedApplyFCARestrictions = false,
|
||||
isBalanceHidden = false,
|
||||
primaryMaximumAmountBoundary = null,
|
||||
primaryMinimumAmountBoundary = null,
|
||||
primaryFiatRateUSD = null,
|
||||
secondaryFiatRateUSD = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildContentQuote(fromAmount: BigDecimal, toAmount: BigDecimal): SwapQuoteUM.Content {
|
||||
return SwapQuoteUM.Content(
|
||||
provider = provider,
|
||||
toAmount = toAmount,
|
||||
fromAmount = fromAmount,
|
||||
toAmountValue = TextReference.EMPTY,
|
||||
fromAmountValue = TextReference.EMPTY,
|
||||
diffPercent = SwapQuoteUM.Content.DifferencePercent.Empty,
|
||||
isSingleProvider = true,
|
||||
rate = TextReference.EMPTY,
|
||||
quoteId = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildContentState(
|
||||
selectedAmountType: SwapAmountType,
|
||||
primaryBalance: BigDecimal?,
|
||||
): SwapAmountUM.Content {
|
||||
val primaryStatus = mockk<CryptoCurrencyStatus>(relaxed = true).also { status ->
|
||||
every { status.value.amount } returns primaryBalance
|
||||
every { status.value.fiatRate } returns null
|
||||
every { status.value.fiatAmount } returns null
|
||||
every { status.currency.symbol } returns "BTC"
|
||||
every { status.currency.decimals } returns 8
|
||||
}
|
||||
return SwapAmountUM.Content(
|
||||
isPrimaryButtonEnabled = false,
|
||||
swapDirection = SwapDirection.Direct,
|
||||
selectedAmountType = selectedAmountType,
|
||||
primaryAmount = SwapAmountFieldUM.Empty(SwapAmountType.From),
|
||||
secondaryAmount = SwapAmountFieldUM.Empty(SwapAmountType.To),
|
||||
primaryCryptoCurrencyStatus = primaryStatus,
|
||||
secondaryCryptoCurrencyStatus = null,
|
||||
swapRateType = ExpressRateType.Fixed,
|
||||
swapRateMode = SwapRateMode.FIXED_ONLY,
|
||||
priceImpact = null,
|
||||
swapCurrencies = SwapCurrencies.EMPTY,
|
||||
swapQuotes = persistentListOf(),
|
||||
selectedQuote = SwapQuoteUM.Empty,
|
||||
isShowFCAWarning = false,
|
||||
appCurrency = null,
|
||||
isShowBestRateAnimation = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -186,6 +186,7 @@ internal class ChooseTokenListItemConverter(
|
|||
PaymentAccountStatusValue.NotCreated,
|
||||
is PaymentAccountStatusValue.UnderReview,
|
||||
PaymentAccountStatusValue.Loading,
|
||||
PaymentAccountStatusValue.Empty,
|
||||
-> return null
|
||||
is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,7 +202,6 @@ internal class ExpressTransactionsModel @Inject constructor(
|
|||
expressTxStatusTaskScheduler.scheduleTask(
|
||||
scope = modelScope,
|
||||
task = PeriodicTask(
|
||||
isDelayFirst = false,
|
||||
delay = EXPRESS_STATUS_UPDATE_DELAY,
|
||||
task = {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -437,7 +437,6 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
expressTxStatusTaskScheduler.scheduleTask(
|
||||
scope = modelScope,
|
||||
task = PeriodicTask(
|
||||
isDelayFirst = false,
|
||||
delay = EXPRESS_STATUS_UPDATE_DELAY,
|
||||
task = {
|
||||
runSuspendCatching {
|
||||
|
|
|
|||
|
|
@ -247,6 +247,10 @@ internal class WalletModel @Inject constructor(
|
|||
} else {
|
||||
null
|
||||
}
|
||||
val isBackedUp = when (selectedWallet) {
|
||||
is UserWallet.Cold -> selectedWallet.scanResponse.card.backupStatus?.isActive == true
|
||||
is UserWallet.Hot -> selectedWallet.backedUp
|
||||
}
|
||||
val result = getAppThemeModeUseCase().firstOrNull()
|
||||
val theme = result?.getOrElse { AppThemeMode.FOLLOW_SYSTEM } ?: AppThemeMode.FOLLOW_SYSTEM
|
||||
val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }.code
|
||||
|
|
@ -254,6 +258,7 @@ internal class WalletModel @Inject constructor(
|
|||
WalletScreenAnalyticsEvent.MainScreen.ScreenOpened(
|
||||
hasMobileWallet = hasMobileWallet,
|
||||
accountsCount = accountsCount,
|
||||
isBackedUp = isBackedUp,
|
||||
theme = theme.value,
|
||||
isImported = selectedWallet.isImported(),
|
||||
referralId = appsFlyerStore.get()?.refcode,
|
||||
|
|
@ -391,7 +396,6 @@ internal class WalletModel @Inject constructor(
|
|||
expressTxStatusTaskScheduler.scheduleTask(
|
||||
modelScope,
|
||||
PeriodicTask(
|
||||
isDelayFirst = false,
|
||||
delay = EXPRESS_STATUS_UPDATE_DELAY,
|
||||
task = {
|
||||
runCatching {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ sealed class WalletScreenAnalyticsEvent {
|
|||
data class ScreenOpened(
|
||||
private val hasMobileWallet: Boolean,
|
||||
private val accountsCount: Int?,
|
||||
private val isBackedUp: Boolean,
|
||||
val theme: String,
|
||||
val isImported: Boolean,
|
||||
val referralId: String?,
|
||||
|
|
@ -71,6 +72,7 @@ sealed class WalletScreenAnalyticsEvent {
|
|||
}
|
||||
put("Wallet Type", seedPhrase)
|
||||
put("App Currency", appCurrency)
|
||||
put("Backuped", if (isBackedUp) "Yes" else "No")
|
||||
putAll(getReferralParams(referralId))
|
||||
},
|
||||
), AppsFlyerIncludedEvent
|
||||
|
|
|
|||
|
|
@ -29,13 +29,13 @@ import com.tangem.domain.promo.models.PromoId
|
|||
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
|
||||
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
|
||||
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
|
||||
import com.tangem.features.hotwallet.HotWalletFeatureToggles
|
||||
import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase
|
||||
import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType
|
||||
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
|
||||
import com.tangem.feature.wallet.impl.R
|
||||
import com.tangem.feature.wallet.presentation.account.AccountDependencies
|
||||
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
|
||||
import com.tangem.features.hotwallet.HotWalletFeatureToggles
|
||||
import com.tangem.hot.sdk.model.HotWalletId
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.utils.extensions.addIf
|
||||
|
|
@ -43,11 +43,7 @@ import com.tangem.utils.extensions.isPositive
|
|||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]")
|
||||
|
|
@ -214,6 +210,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
|
|||
is PaymentAccountStatusValue.Loaded,
|
||||
is PaymentAccountStatusValue.Loading,
|
||||
is PaymentAccountStatusValue.UnderReview,
|
||||
is PaymentAccountStatusValue.Empty,
|
||||
-> null
|
||||
}
|
||||
notification?.let(::add)
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ internal class GetWalletNotificationsFactory @Inject constructor(
|
|||
is PaymentAccountStatusValue.Loaded,
|
||||
is PaymentAccountStatusValue.Loading,
|
||||
is PaymentAccountStatusValue.UnderReview,
|
||||
is PaymentAccountStatusValue.Empty,
|
||||
-> null
|
||||
}
|
||||
notification?.let(::add)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ internal class TangemPayMainBlockConverter(
|
|||
}
|
||||
},
|
||||
)
|
||||
is PaymentAccountStatusValue.Empty -> TangemPayMainUM.Empty
|
||||
is PaymentAccountStatusValue.NotCreated -> TangemPayMainUM.Empty
|
||||
is PaymentAccountStatusValue.Loading -> TangemPayMainUM.Loading
|
||||
is PaymentAccountStatusValue.Loaded -> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue