Updated on 2026-08-14

This commit is contained in:
Tangem 2024-12-11 18:27:56 +03:00
commit 721258e093
39 changed files with 469 additions and 109 deletions

View file

@ -58,6 +58,28 @@ internal object TransactionDomainModule {
)
}
@Provides
@Singleton
fun provideRetryTransactionUseCase(
cardSdkConfigRepository: CardSdkConfigRepository,
walletManagersFacade: WalletManagersFacade,
): RetryIncompleteTransactionUseCase {
return RetryIncompleteTransactionUseCase(
cardSdkConfigRepository = cardSdkConfigRepository,
walletManagersFacade = walletManagersFacade,
)
}
@Provides
@Singleton
fun provideDismissIncompleteTransactionUseCase(
walletManagersFacade: WalletManagersFacade,
): DismissIncompleteTransactionUseCase {
return DismissIncompleteTransactionUseCase(
walletManagersFacade = walletManagersFacade,
)
}
@Provides
@Singleton
fun provideCreateTransactionUseCase(transactionRepository: TransactionRepository): CreateTransactionUseCase {

View file

@ -212,7 +212,6 @@ internal class MainViewModel @Inject constructor(
override fun onDismissBottomSheet() {
listenToFlipsUseCase.changeUpdateEnabled(true)
router.pop()
stateHolder.updateWithoutModalNotification()
stateHolder.updateWithHiddenBalancesToast(true)
}

View file

@ -228,9 +228,13 @@ sealed class NotificationUM(val config: NotificationConfig) {
),
)
data class OnrampErrorNotification(val onRefresh: () -> Unit) : Warning(
data class OnrampErrorNotification(val errorCode: String?, val onRefresh: () -> Unit) : Warning(
title = resourceReference(R.string.common_error),
subtitle = resourceReference(R.string.common_unknown_error),
subtitle = if (errorCode != null) {
resourceReference(R.string.express_error_code, wrappedList(errorCode))
} else {
resourceReference(R.string.common_unknown_error)
},
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = onRefresh,

View file

@ -185,7 +185,7 @@ object NotificationsFactory {
if (dustValue == null) return
val isExceedsLimit = checkDustLimits(
feeAmount = feeValue,
receivedAmount = sendingAmount,
sendingAmount = sendingAmount,
dustValue = dustValue,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCurrencyStatus = feeCurrencyStatus,
@ -352,7 +352,7 @@ object NotificationsFactory {
private fun checkDustLimits(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
sendingAmount: BigDecimal,
dustValue: BigDecimal,
cryptoCurrencyStatus: CryptoCurrencyStatus,
feeCurrencyStatus: CryptoCurrencyStatus?,
@ -360,7 +360,7 @@ object NotificationsFactory {
val change = when (cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> {
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
balance - (feeAmount + receivedAmount)
balance - (feeAmount + sendingAmount)
}
is CryptoCurrency.Token -> {
val balance = feeCurrencyStatus?.value?.amount ?: BigDecimal.ZERO
@ -369,6 +369,9 @@ object NotificationsFactory {
}
val isChangeLowerThanDust = change < dustValue && change > BigDecimal.ZERO
return receivedAmount < dustValue || isChangeLowerThanDust
return when (cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> sendingAmount < dustValue || isChangeLowerThanDust
is CryptoCurrency.Token -> isChangeLowerThanDust
}
}
}

View file

@ -222,13 +222,23 @@ private fun Buttons(state: NotificationButtonsState?, isEnabled: Boolean = true)
@Composable
private fun SingleSecondaryButton(config: NotificationButtonsState.SecondaryButtonConfig, isEnabled: Boolean = true) {
SecondaryButton(
text = config.text.resolveReference(),
onClick = config.onClick,
modifier = Modifier.fillMaxWidth(),
size = TangemButtonSize.WideAction,
enabled = isEnabled,
)
if (config.iconResId != null) {
SecondaryButtonIconEnd(
text = config.text.resolveReference(),
onClick = config.onClick,
modifier = Modifier.fillMaxWidth(),
iconResId = config.iconResId,
enabled = isEnabled,
)
} else {
SecondaryButton(
text = config.text.resolveReference(),
onClick = config.onClick,
modifier = Modifier.fillMaxWidth(),
size = TangemButtonSize.WideAction,
enabled = isEnabled,
)
}
}
@Composable

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M10.833,10.833H9.167V5.833H10.833M10.833,14.167H9.167V12.5H10.833M10,1.667C8.906,1.667 7.822,1.882 6.811,2.301C5.8,2.72 4.881,3.334 4.107,4.108C2.545,5.67 1.667,7.79 1.667,10C1.667,12.21 2.545,14.33 4.107,15.893C4.881,16.667 5.8,17.28 6.811,17.699C7.822,18.118 8.906,18.333 10,18.333C12.21,18.333 14.33,17.455 15.892,15.893C17.455,14.33 18.333,12.21 18.333,10C18.333,8.906 18.118,7.822 17.699,6.811C17.28,5.8 16.666,4.881 15.892,4.108C15.119,3.334 14.2,2.72 13.189,2.301C12.178,1.882 11.094,1.667 10,1.667Z"
android:fillColor="#FF3333"/>
</vector>

View file

@ -52,6 +52,7 @@ internal class DefaultCustomTokensRepository(
Blockchain.Unknown,
Blockchain.Binance,
Blockchain.BinanceTestnet,
Blockchain.Kaspa,
-> true
Blockchain.Cardano -> blockchain.validateContractAddress(contractAddress)
else -> blockchain.validateAddress(contractAddress)

View file

@ -304,10 +304,11 @@ internal class DefaultMarketsTokenRepository(
}
private fun createListErrorEvent(error: ApiResponseError): MarketsDataAnalyticsEvent.List.Error {
return createErrorEvent(error) { errorType, errorCode ->
return createErrorEvent(error) { errorType, errorCode, errorMessage ->
MarketsDataAnalyticsEvent.List.Error(
errorType = errorType,
errorCode = errorCode,
errorMessage = errorMessage,
)
}
}
@ -317,10 +318,11 @@ internal class DefaultMarketsTokenRepository(
request: MarketsDataAnalyticsEvent.Details.Error.Request,
tokenSymbol: String,
): MarketsDataAnalyticsEvent.Details.Error {
return createErrorEvent(error) { errorType, errorCode ->
return createErrorEvent(error) { errorType, errorCode, errorMessage ->
MarketsDataAnalyticsEvent.Details.Error(
errorType = errorType,
errorCode = errorCode,
errorMessage = errorMessage,
request = request,
tokenSymbol = tokenSymbol,
)
@ -329,20 +331,20 @@ internal class DefaultMarketsTokenRepository(
private inline fun <T> createErrorEvent(
error: ApiResponseError,
createEvent: (MarketsDataAnalyticsEvent.Type, Int?) -> T,
createEvent: (MarketsDataAnalyticsEvent.Type, Int?, String) -> T,
): T {
return when (error) {
is ApiResponseError.HttpException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Http, error.code.code)
createEvent(MarketsDataAnalyticsEvent.Type.Http, error.code.code, error.message.orEmpty())
}
is ApiResponseError.TimeoutException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Timeout, null)
createEvent(MarketsDataAnalyticsEvent.Type.Timeout, null, error.message.orEmpty())
}
is ApiResponseError.NetworkException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Network, null)
createEvent(MarketsDataAnalyticsEvent.Type.Network, null, error.message.orEmpty())
}
is ApiResponseError.UnknownException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Unknown, null)
createEvent(MarketsDataAnalyticsEvent.Type.Unknown, null, error.message.orEmpty())
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.markets.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed interface MarketsDataAnalyticsEvent {
@ -12,11 +13,13 @@ sealed interface MarketsDataAnalyticsEvent {
data class Error(
val errorType: Type,
val errorCode: Int? = null,
val errorMessage: String,
) : List(
event = "Data Error",
params = buildMap {
put("Error Type", errorType.value)
errorCode?.let { put("Error Code", it.toString()) }
put(AnalyticsParam.ERROR_TYPE, errorType.value)
put(AnalyticsParam.ERROR_CODE, errorCode?.toString() ?: IS_NOT_HTTP_ERROR)
put(AnalyticsParam.ERROR_MESSAGE, errorMessage)
},
)
}
@ -31,13 +34,15 @@ sealed interface MarketsDataAnalyticsEvent {
val tokenSymbol: String,
val errorType: Type,
val errorCode: Int? = null,
val errorMessage: String,
) : Details(
event = "Data Error",
params = buildMap {
put("Source", request.source)
put("Token", tokenSymbol)
errorCode?.let { put("Error Code", it.toString()) }
put("Error Type", errorType.value)
put(AnalyticsParam.ERROR_TYPE, errorType.value)
put(AnalyticsParam.ERROR_CODE, errorCode?.toString() ?: IS_NOT_HTTP_ERROR)
put(AnalyticsParam.ERROR_MESSAGE, errorMessage)
},
) {
@ -63,9 +68,9 @@ sealed interface MarketsDataAnalyticsEvent {
event = "Data Error",
params = buildMap {
put("Request path", requestPath)
errorCode?.let { put("Error Code", it.toString()) }
put("Error Type", errorType.value)
put("Error Description", "Chart data contains null values from the API")
put(AnalyticsParam.ERROR_TYPE, errorType.value)
put(AnalyticsParam.ERROR_CODE, errorCode?.toString() ?: IS_NOT_HTTP_ERROR)
put(AnalyticsParam.ERROR_MESSAGE, "Chart data contains null values from the API")
},
),
MarketsDataAnalyticsEvent
@ -77,4 +82,8 @@ sealed interface MarketsDataAnalyticsEvent {
Custom("Custom"),
Unknown("Unknown"),
}
private companion object {
const val IS_NOT_HTTP_ERROR = "Is not http error"
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.blockchain.common.ReserveAmountProvider
import com.tangem.blockchain.common.UtxoAmountLimitProvider
import com.tangem.data.tokens.converters.UtxoConverter
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.CurrencyAmount
import com.tangem.domain.tokens.model.Network
@ -24,6 +25,7 @@ internal class DefaultCurrencyChecksRepository(
private val walletManagersFacade: WalletManagersFacade,
private val coroutineDispatchers: CoroutineDispatcherProvider,
) : CurrencyChecksRepository {
override suspend fun getExistentialDeposit(userWalletId: UserWalletId, network: Network): BigDecimal? {
val manager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
@ -108,6 +110,7 @@ internal class DefaultCurrencyChecksRepository(
override suspend fun checkUtxoAmountLimit(
userWalletId: UserWalletId,
network: Network,
currency: CryptoCurrency,
amount: BigDecimal,
fee: BigDecimal,
): UtxoAmountLimit? {

View file

@ -594,7 +594,7 @@ class DefaultWalletManagersFacade(
}
}
override suspend fun associateAsset(
override suspend fun fulfillRequirements(
userWalletId: UserWalletId,
currency: CryptoCurrency,
signer: TransactionSigner,
@ -613,6 +613,21 @@ class DefaultWalletManagersFacade(
}
}
override suspend fun discardRequirements(userWalletId: UserWalletId, currency: CryptoCurrency): SimpleResult {
return withContext(dispatchers.io) {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = currency.network)
val currencyType = cryptoCurrencyTypeConverter.convert(currency)
if (walletManager !is AssetRequirementsManager) {
return@withContext SimpleResult.Failure(
BlockchainSdkError.CustomError("WalletManager is not implemented AssetRequirementsManager"),
)
}
walletManager.discardRequirements(currencyType)
}
}
override suspend fun checkUtxoConsolidationAvailability(userWalletId: UserWalletId, network: Network): Boolean {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(

View file

@ -233,12 +233,14 @@ interface WalletManagersFacade {
*/
suspend fun getAssetRequirements(userWalletId: UserWalletId, currency: CryptoCurrency): AssetRequirementsCondition?
suspend fun associateAsset(
suspend fun fulfillRequirements(
userWalletId: UserWalletId,
currency: CryptoCurrency,
signer: TransactionSigner,
): SimpleResult
suspend fun discardRequirements(userWalletId: UserWalletId, currency: CryptoCurrency): SimpleResult
/**
* Indicates UTXO consolidation availability
*

View file

@ -7,13 +7,20 @@ import com.tangem.blockchain.common.trustlines.AssetRequirementsCondition as Sdk
internal class SdkRequirementsConditionConverter : Converter<SdkRequirementsCondition, AssetRequirementsCondition> {
override fun convert(value: SdkRequirementsCondition): AssetRequirementsCondition {
return when (value) {
SdkRequirementsCondition.PaidTransaction -> AssetRequirementsCondition.PaidTransaction
is SdkRequirementsCondition.PaidTransaction -> AssetRequirementsCondition.PaidTransaction
is SdkRequirementsCondition.PaidTransactionWithFee -> AssetRequirementsCondition.PaidTransactionWithFee(
feeAmount = requireNotNull(value.feeAmount.value),
feeCurrencySymbol = value.feeAmount.currencySymbol,
decimals = value.feeAmount.decimals,
)
is SdkRequirementsCondition.IncompleteTransaction -> TODO()
is SdkRequirementsCondition.IncompleteTransaction -> AssetRequirementsCondition.IncompleteTransaction(
amount = requireNotNull(value.amount.value),
currencySymbol = value.amount.currencySymbol,
currencyDecimals = value.amount.decimals,
feeAmount = requireNotNull(value.feeAmount.value),
feeCurrencySymbol = value.feeAmount.currencySymbol,
feeCurrencyDecimals = value.feeAmount.decimals,
)
}
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.tokens.model.warnings
import com.tangem.domain.tokens.model.CryptoCurrency
import java.math.BigDecimal
sealed class KaspaWarnings : CryptoCurrencyWarning() {
abstract val currency: CryptoCurrency
data class IncompleteTransaction(
override val currency: CryptoCurrency,
val amount: BigDecimal,
val currencySymbol: String,
val currencyDecimals: Int,
) : KaspaWarnings()
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.tokens
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
@ -22,7 +23,8 @@ class GetCurrencyCheckUseCase(
recipientAddress: String? = null,
): CryptoCurrencyCheck {
return withContext(dispatchers.io) {
val network = currencyStatus.currency.network
val currency = currencyStatus.currency
val network = currency.network
val dustValue = currencyChecksRepository.getDustValue(userWalletId, network)
val reserveAmount = currencyChecksRepository.getReserveAmount(userWalletId, network)
val minimumSendAmount = currencyChecksRepository.getMinimumSendAmount(userWalletId, network)
@ -39,10 +41,11 @@ class GetCurrencyCheckUseCase(
recipientAddress,
)
} ?: false
val utxoAmountLimit = if (amount != null && fee != null) {
val utxoAmountLimit = if (currency is CryptoCurrency.Coin && amount != null && fee != null) {
currencyChecksRepository.checkUtxoAmountLimit(
userWalletId = userWalletId,
network = network,
currency = currencyStatus.currency,
amount = amount,
fee = fee,
)

View file

@ -6,6 +6,7 @@ import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.model.*
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations
import com.tangem.domain.tokens.repository.*
import com.tangem.domain.transaction.models.AssetRequirementsCondition
@ -20,7 +21,7 @@ import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.flow.*
import java.math.BigDecimal
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
class GetCurrencyWarningsUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val currenciesRepository: CurrenciesRepository,
@ -79,7 +80,17 @@ class GetCurrencyWarningsUseCase(
getAssetRequirementsWarning(userWalletId = userWalletId, currency = currency),
getMigrationFromMaticToPolWarning(currency),
)
}.flowOn(dispatchers.io)
}
.onEmpty {
setOfNotNull(
getNetworkUnavailableWarning(currencyStatus),
getNetworkNoAccountWarning(currencyStatus),
getBeaconChainShutdownWarning(currency.network.id),
getAssetRequirementsWarning(userWalletId = userWalletId, currency = currency),
getMigrationFromMaticToPolWarning(currency),
)
}
.flowOn(dispatchers.io)
}
private suspend fun getSwapPromoNotificationWarning(
@ -298,12 +309,22 @@ class GetCurrencyWarningsUseCase(
): CryptoCurrencyWarning? {
return when (val requirements = walletManagersFacade.getAssetRequirements(userWalletId, currency)) {
is AssetRequirementsCondition.PaidTransaction -> HederaWarnings.AssociateWarning(currency = currency)
is AssetRequirementsCondition.PaidTransactionWithFee -> HederaWarnings.AssociateWarningWithFee(
currency = currency,
fee = requirements.feeAmount,
feeCurrencySymbol = requirements.feeCurrencySymbol,
feeCurrencyDecimals = requirements.decimals,
)
is AssetRequirementsCondition.PaidTransactionWithFee -> {
HederaWarnings.AssociateWarningWithFee(
currency = currency,
fee = requirements.feeAmount,
feeCurrencySymbol = requirements.feeCurrencySymbol,
feeCurrencyDecimals = requirements.decimals,
)
}
is AssetRequirementsCondition.IncompleteTransaction ->
KaspaWarnings.IncompleteTransaction(
currency = currency,
amount = requirements.amount,
currencySymbol = requirements.currencySymbol,
currencyDecimals = requirements.currencyDecimals,
)
null -> null
}
}

View file

@ -167,7 +167,10 @@ internal class CurrenciesStatusesLceOperations(
private fun getQuotes(tokensIds: NonEmptySet<CryptoCurrency.ID>): Flow<Either<TokenListError, Set<Quote>>> {
return quotesRepository.getQuotesUpdates(tokensIds)
.map<Set<Quote>, Either<TokenListError, Set<Quote>>> { it.right() }
.catch { emit(TokenListError.DataError(it).left()) }
.retryWhen { cause, _ ->
emit(TokenListError.DataError(cause).left())
true
}
.distinctUntilChanged()
}

View file

@ -348,8 +348,9 @@ internal class CurrenciesStatusesOperations(
.map<Set<Quote>, Either<Error, Set<Quote>>> { quotes ->
if (quotes.isEmpty()) Error.EmptyQuotes.left() else quotes.right()
}
.catch {
emit(Error.DataError(it).left())
.retryWhen { cause, _ ->
emit(Error.DataError(cause).left())
true
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.tokens.repository
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.CurrencyAmount
import com.tangem.domain.tokens.model.Network
@ -38,6 +39,7 @@ interface CurrencyChecksRepository {
suspend fun checkUtxoAmountLimit(
userWalletId: UserWalletId,
network: Network,
currency: CryptoCurrency,
amount: BigDecimal,
fee: BigDecimal,
): UtxoAmountLimit?

View file

@ -17,4 +17,16 @@ sealed class AssetRequirementsCondition {
val feeCurrencySymbol: String,
val decimals: Int,
) : AssetRequirementsCondition()
/**
* The exact value of the fee for this type of condition is stored in `feeAmount`.
*/
data class IncompleteTransaction(
val amount: BigDecimal,
val currencySymbol: String,
val currencyDecimals: Int,
val feeAmount: BigDecimal,
val feeCurrencySymbol: String,
val feeCurrencyDecimals: Int,
) : AssetRequirementsCondition()
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.transaction.error
sealed class IncompleteTransactionError {
data class DataError(val message: String?) : IncompleteTransactionError()
}

View file

@ -39,7 +39,7 @@ class AssociateAssetUseCase(
catch(
block = {
when (val result = walletManagersFacade.associateAsset(userWalletId, currency, signer)) {
when (val result = walletManagersFacade.fulfillRequirements(userWalletId, currency, signer)) {
is SimpleResult.Failure -> raise(AssociateAssetError.DataError(result.error.customMessage))
SimpleResult.Success -> Unit
}

View file

@ -0,0 +1,34 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.transaction.error.IncompleteTransactionError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
class DismissIncompleteTransactionUseCase(
private val walletManagersFacade: WalletManagersFacade,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): Either<IncompleteTransactionError, Unit> {
return either {
catch(
block = {
when (val result = walletManagersFacade.discardRequirements(userWalletId, currency)) {
is SimpleResult.Failure -> raise(
IncompleteTransactionError.DataError(result.error.customMessage),
)
SimpleResult.Success -> Unit
}
},
catch = { error -> IncompleteTransactionError.DataError(error.message) },
)
}
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.transaction.error.IncompleteTransactionError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
class RetryIncompleteTransactionUseCase(
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val walletManagersFacade: WalletManagersFacade,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): Either<IncompleteTransactionError, Unit> {
return either {
val signer = cardSdkConfigRepository.getCommonSigner(cardId = null)
catch(
block = {
when (val result = walletManagersFacade.fulfillRequirements(userWalletId, currency, signer)) {
is SimpleResult.Failure -> raise(
IncompleteTransactionError.DataError(result.error.customMessage),
)
SimpleResult.Success -> Unit
}
},
catch = { error -> IncompleteTransactionError.DataError(error.message) },
)
}
}
}

View file

@ -49,9 +49,8 @@ internal class OnrampStateFactory(
fun getOnrampErrorState(onrampError: OnrampError): OnrampMainComponentUM {
return when (onrampError) {
OnrampError.PairsNotFound -> getNoPairsErrorState()
is OnrampError.DataError,
is OnrampError.DomainError,
-> getErrorState()
is OnrampError.DataError -> getErrorState(onrampError.code)
is OnrampError.DomainError -> getErrorState()
is OnrampError.AmountError.TooBigError,
is OnrampError.AmountError.TooSmallError,
OnrampError.RedirectError.VerificationFailed,
@ -75,7 +74,7 @@ internal class OnrampStateFactory(
)
}
private fun getErrorState(): OnrampMainComponentUM {
private fun getErrorState(errorCode: String? = null): OnrampMainComponentUM {
val state = currentStateProvider()
val endButton = state.topBarConfig.endButtonUM.copy(enabled = true)
@ -87,10 +86,16 @@ internal class OnrampStateFactory(
amountFieldModel = state.amountBlockState.amountFieldModel.copy(isError = true),
),
providerBlockState = OnrampProviderBlockUM.Empty,
errorNotification = NotificationUM.Warning.OnrampErrorNotification(onrampIntents::onRefresh),
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onrampIntents::onRefresh,
),
)
is OnrampMainComponentUM.InitialLoading -> state.copy(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(onrampIntents::onRefresh),
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onrampIntents::onRefresh,
),
)
}
}

View file

@ -202,6 +202,7 @@ internal class OnrampMainComponentModel @Inject constructor(
val bestProvider = quote as? OnrampQuote.Data
val isMultipleQuotes = !quotes.isSingleItem()
val isOtherQuotesHasData = quotes
.filter { it.paymentMethod == quote.paymentMethod }
.filterNot { it == bestProvider }
.any { it is OnrampQuote.Data }
val hasBestProvider = isMultipleQuotes && isOtherQuotesHasData

View file

@ -71,6 +71,7 @@ internal class UpdateTokenItemsTransformer(
private fun createAvailableTokenItemStateConverter(): TokenItemStateConverter {
return TokenItemStateConverter(
appCurrency = appCurrency,
subtitleStateProvider = { createSubtitleState(status = it, isAvailable = true) },
subtitle2StateProvider = ::createSubtitle2State,
fiatAmountStateProvider = { createFiatAmountStateProvider(status = it, isAvailable = true) },
onItemClick = onItemClick,
@ -87,22 +88,24 @@ internal class UpdateTokenItemsTransformer(
isAvailable = false,
)
},
subtitleStateProvider = {
when (it.value) {
CryptoCurrencyStatus.Loading -> TokenItemState.SubtitleState.Loading
else -> {
TokenItemState.SubtitleState.TextContent(
value = stringReference(value = it.currency.symbol),
isAvailable = false,
)
}
}
},
subtitleStateProvider = { createSubtitleState(status = it, isAvailable = false) },
subtitle2StateProvider = ::createSubtitle2State,
fiatAmountStateProvider = { createFiatAmountStateProvider(status = it, isAvailable = false) },
)
}
private fun createSubtitleState(status: CryptoCurrencyStatus, isAvailable: Boolean): TokenItemState.SubtitleState {
return when (status.value) {
CryptoCurrencyStatus.Loading -> TokenItemState.SubtitleState.Loading
else -> {
TokenItemState.SubtitleState.TextContent(
value = stringReference(value = status.currency.symbol),
isAvailable = isAvailable,
)
}
}
}
private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? {
return when (status.value) {
is CryptoCurrencyStatus.Loaded,

View file

@ -25,6 +25,9 @@ import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -133,17 +136,25 @@ internal class OnrampTokenListModel @Inject constructor(
}
private suspend fun List<CryptoCurrencyStatus>.filterByAvailability(): Map<Boolean, List<CryptoCurrencyStatus>> {
return groupBy { status ->
val isAvailable = checkAvailabilityByOperation(status = status)
val isNotMissedDerivation = status.value !is CryptoCurrencyStatus.MissedDerivation
return coroutineScope {
map { status ->
async {
val isAvailable = checkAvailabilityByOperation(status = status)
val isNotMissedDerivation = status.value !is CryptoCurrencyStatus.MissedDerivation
val isNotLoading = status.value !is CryptoCurrencyStatus.Loading
val isNotUnreachable = when (params.filterOperation) {
OnrampOperation.BUY -> true // unreachable state is available for Buy operation
OnrampOperation.SELL -> status.value !is CryptoCurrencyStatus.Unreachable
OnrampOperation.SWAP -> status.value !is CryptoCurrencyStatus.Unreachable
val isNotUnreachable = when (params.filterOperation) {
OnrampOperation.BUY -> true // unreachable state is available for Buy operation
OnrampOperation.SELL -> status.value !is CryptoCurrencyStatus.Unreachable
OnrampOperation.SWAP -> status.value !is CryptoCurrencyStatus.Unreachable
}
status to (isAvailable && isNotMissedDerivation && isNotLoading && isNotUnreachable)
}
}
isAvailable && isNotMissedDerivation && isNotUnreachable
.awaitAll()
.groupBy(Pair<CryptoCurrencyStatus, Boolean>::second)
.mapValues { it.value.map(Pair<CryptoCurrencyStatus, Boolean>::first) }
}
}

View file

@ -30,9 +30,7 @@ internal class SwapRouter(
* If select token screen is not in stack, then just pop to previous screen.
* Otherwise, pop to previous screen that was before select token screen.
*/
if (selectTokensIndex == null) {
router.pop()
} else {
if (currentScreen == SwapNavScreen.Success && selectTokensIndex != null) {
// find previous screen that was before select token
val prevRoute = router.stack.getOrNull(index = selectTokensIndex - 1)
@ -41,6 +39,8 @@ internal class SwapRouter(
} else {
router.pop()
}
} else {
router.pop()
}
}
}

View file

@ -48,6 +48,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.RentInfo,
is TokenDetailsNotification.NetworkShutdown,
is TokenDetailsNotification.HederaAssociateWarning,
is TokenDetailsNotification.KaspaIncompleteTransactionWarning,
is TokenDetailsNotification.KoinosMana,
is TokenDetailsNotification.MigrationMaticToPol,
-> null

View file

@ -96,6 +96,27 @@ internal data class TokenDetailsDialogConfig(
)
}
data class RemoveIncompleteTransactionConfirmDialogConfig(
val onConfirmClick: () -> Unit,
val onCancelClick: () -> Unit,
) : DialogContentConfig() {
override val title = null
override val message: TextReference = TextReference.Res(
id = R.string.warning_kaspa_unfinished_token_transaction_discard_message,
)
override val cancelButtonConfig: ButtonConfig = ButtonConfig(
text = TextReference.Res(R.string.common_cancel),
onClick = onCancelClick,
)
override val confirmButtonConfig: ButtonConfig = ButtonConfig(
text = TextReference.Res(R.string.common_yes),
onClick = onConfirmClick,
)
}
data class ErrorDialogConfig(
val text: TextReference,
val onConfirmClick: () -> Unit,

View file

@ -19,12 +19,14 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
subtitle: TextReference,
iconResId: Int = R.drawable.img_attention_20,
buttonsState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : TokenDetailsNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = iconResId,
buttonsState = buttonsState,
onCloseClick = onCloseClick,
),
)
@ -191,6 +193,27 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
),
)
data class KaspaIncompleteTransactionWarning(
private val currency: CryptoCurrency,
private val amount: String,
private val currencySymbol: String,
private val onRetryIncompleteTransactionClick: () -> Unit,
private val onDismissIncompleteTransactionClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.warning_kaspa_unfinished_token_transaction_title),
subtitle = resourceReference(
id = R.string.warning_kaspa_unfinished_token_transaction_message,
formatArgs = wrappedList(amount, currencySymbol),
),
iconResId = R.drawable.ic_alert_circle_red_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.alert_button_try_again),
onClick = onRetryIncompleteTransactionClick,
iconResId = R.drawable.ic_tangem_24,
),
onCloseClick = onDismissIncompleteTransactionClick,
)
data class KoinosMana(
val manaBalanceAmount: String,
val maxManaBalanceAmount: String,

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.format.bigdecimal.shorted
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification.*
@ -41,6 +42,14 @@ internal class TokenDetailsNotificationConverter(
return newNotifications.toImmutableList()
}
fun removeKaspaIncompleteTransactionWarning(
currentState: TokenDetailsState,
): ImmutableList<TokenDetailsNotification> {
val newNotifications = currentState.notifications.toMutableList()
newNotifications.removeBy { it is KaspaIncompleteTransactionWarning }
return newNotifications.toImmutableList()
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun mapToNotification(warning: CryptoCurrencyWarning): TokenDetailsNotification {
return when (warning) {
@ -109,6 +118,13 @@ internal class TokenDetailsNotificationConverter(
feeCurrencySymbol = warning.feeCurrencySymbol,
onAssociateClick = clickIntents::onAssociateClick,
)
is KaspaWarnings.IncompleteTransaction -> KaspaIncompleteTransactionWarning(
currency = warning.currency,
amount = warning.amount.format { crypto(symbol = "", decimals = warning.currencyDecimals) },
currencySymbol = warning.currencySymbol,
onRetryIncompleteTransactionClick = clickIntents::onRetryIncompleteTransactionClick,
onDismissIncompleteTransactionClick = clickIntents::onDismissIncompleteTransactionClick,
)
is CryptoCurrencyWarning.FeeResourceInfo -> KoinosMana(
manaBalanceAmount = formatMana(warning.amount),
maxManaBalanceAmount = warning.maxAmount?.let {

View file

@ -199,6 +199,19 @@ internal class TokenDetailsStateFactory(
)
}
fun getStateWithDismissIncompleteTransactionConfirmDialog(): TokenDetailsState {
return currentStateProvider().copy(
dialogConfig = TokenDetailsDialogConfig(
isShow = true,
onDismissRequest = clickIntents::onDismissDialog,
content = TokenDetailsDialogConfig.DialogContentConfig.RemoveIncompleteTransactionConfirmDialogConfig(
onConfirmClick = clickIntents::onConfirmDismissIncompleteTransactionClick,
onCancelClick = clickIntents::onDismissDialog,
),
),
)
}
fun getStateWithActionButtonErrorDialog(unavailabilityReason: ScenarioUnavailabilityReason): TokenDetailsState {
return currentStateProvider().copy(
dialogConfig = TokenDetailsDialogConfig(
@ -300,6 +313,14 @@ internal class TokenDetailsStateFactory(
return state.copy(notifications = notificationConverter.removeHederaAssociateWarning(state))
}
fun getStateWithRemovedKaspaIncompleteTransactionNotification(): TokenDetailsState {
val state = currentStateProvider()
return state.copy(
notifications = notificationConverter.removeKaspaIncompleteTransactionWarning(state),
dialogConfig = state.dialogConfig?.copy(isShow = false),
)
}
fun getStateAndTriggerEvent(
state: TokenDetailsState,
errorMessage: TextReference,

View file

@ -59,6 +59,12 @@ interface TokenDetailsClickIntents {
fun onAssociateClick()
fun onRetryIncompleteTransactionClick()
fun onDismissIncompleteTransactionClick()
fun onConfirmDismissIncompleteTransactionClick()
fun onStakeBannerClick()
fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig)

View file

@ -50,7 +50,10 @@ import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Companion.toReasonAnalyticsText
import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent
import com.tangem.domain.transaction.error.AssociateAssetError
import com.tangem.domain.transaction.error.IncompleteTransactionError
import com.tangem.domain.transaction.usecase.AssociateAssetUseCase
import com.tangem.domain.transaction.usecase.DismissIncompleteTransactionUseCase
import com.tangem.domain.transaction.usecase.RetryIncompleteTransactionUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase
@ -108,6 +111,8 @@ internal class TokenDetailsViewModel @Inject constructor(
private val networkHasDerivationUseCase: NetworkHasDerivationUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val associateAssetUseCase: AssociateAssetUseCase,
private val retryIncompleteTransactionUseCase: RetryIncompleteTransactionUseCase,
private val dismissIncompleteTransactionUseCase: DismissIncompleteTransactionUseCase,
private val reduxStateHolder: ReduxStateHolder,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val vibratorHapticManager: VibratorHapticManager,
@ -861,6 +866,58 @@ internal class TokenDetailsViewModel @Inject constructor(
return resourceReference(R.string.wallet_notification_address_copied)
}
override fun onRetryIncompleteTransactionClick() {
viewModelScope.launch {
retryIncompleteTransactionUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifLeft = { e ->
when (e) {
is IncompleteTransactionError.DataError -> {
internalUiState.value = stateFactory.getStateWithErrorDialog(
stringReference(e.message.orEmpty()),
)
Timber.e(e.message)
}
}
},
ifRight = {
internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
},
)
}
}
override fun onDismissIncompleteTransactionClick() {
viewModelScope.launch {
internalUiState.value = stateFactory.getStateWithDismissIncompleteTransactionConfirmDialog()
}
}
override fun onConfirmDismissIncompleteTransactionClick() {
viewModelScope.launch {
dismissIncompleteTransactionUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifLeft = { e ->
when (e) {
is IncompleteTransactionError.DataError -> {
internalUiState.value = stateFactory.getStateWithErrorDialog(
stringReference(e.message.orEmpty()),
)
Timber.e(e.message)
}
}
},
ifRight = {
internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
},
)
}
}
override fun onAssociateClick() {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.Associate(

View file

@ -47,7 +47,6 @@ import com.tangem.feature.wallet.presentation.wallet.domain.unwrap
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.DisableActionTransformer
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.scopes.ViewModelScoped
@ -59,7 +58,6 @@ import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
import kotlin.reflect.KClass
interface WalletCurrencyActionsClickIntents {
@ -456,8 +454,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
val userCountry = getUserCountryUseCase().getOrNull()
if (userCountry is UserCountry.Russia) {
handleError(
userWalletId = userWalletId,
actionKClass = WalletManageButton.Sell::class,
alertState = WalletAlertState.SellingRegionalRestriction,
eventCreator = MainScreenAnalyticsEvent::ButtonSell,
)
@ -468,8 +464,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
val selectedWallet = stateHolder.getSelectedWallet().walletCardState as? WalletCardState.Content
if (selectedWallet?.isZeroBalance == true) {
handleError(
userWalletId = userWalletId,
actionKClass = WalletManageButton.Sell::class,
alertState = WalletAlertState.InsufficientBalanceForSelling,
eventCreator = MainScreenAnalyticsEvent::ButtonSell,
)
@ -478,10 +472,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
onMultiWalletActionClick(
userWalletId = userWalletId,
statusFlow = rampStateManager.getSellInitializationStatus(),
route = AppRoute.SellCrypto(userWalletId = userWalletId),
actionKClass = WalletManageButton.Sell::class,
eventCreator = MainScreenAnalyticsEvent::ButtonSell,
)
}
@ -493,8 +485,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
if (tokenListState.items.count { it is TokensListItemUM.Token } < 2) {
handleError(
userWalletId = userWalletId,
actionKClass = WalletManageButton.Swap::class,
alertState = WalletAlertState.InsufficientTokensCountForSwapping,
eventCreator = MainScreenAnalyticsEvent::ButtonSwap,
)
@ -503,20 +493,16 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
onMultiWalletActionClick(
userWalletId = userWalletId,
statusFlow = rampStateManager.getSwapInitializationStatus(userWalletId),
route = AppRoute.SwapCrypto(userWalletId = userWalletId),
actionKClass = WalletManageButton.Swap::class,
eventCreator = MainScreenAnalyticsEvent::ButtonSwap,
)
}
override fun onMultiWalletBuyClick(userWalletId: UserWalletId) {
onMultiWalletActionClick(
userWalletId = userWalletId,
statusFlow = rampStateManager.getBuyInitializationStatus(),
route = AppRoute.BuyCrypto(userWalletId = userWalletId),
actionKClass = WalletManageButton.Buy::class,
eventCreator = MainScreenAnalyticsEvent::ButtonBuy,
)
}
@ -620,22 +606,14 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
private fun onMultiWalletActionClick(
userWalletId: UserWalletId,
statusFlow: Flow<Lce<Throwable, Any>>,
route: AppRoute,
actionKClass: KClass<out WalletManageButton>,
eventCreator: (AnalyticsParam.Status) -> MainScreenAnalyticsEvent,
) {
viewModelScope.launch {
statusFlow.foldStatus(
onContent = { handleContent(route, eventCreator) },
onError = {
handleError(
userWalletId = userWalletId,
actionKClass = actionKClass,
eventCreator = eventCreator,
)
},
onError = { handleError(eventCreator = eventCreator) },
onLoading = { handleLoading(eventCreator) },
)
}
@ -662,17 +640,11 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
private fun handleError(
userWalletId: UserWalletId,
actionKClass: KClass<out WalletManageButton>,
alertState: WalletAlertState = WalletAlertState.UnavailableOperation,
eventCreator: (AnalyticsParam.Status) -> MainScreenAnalyticsEvent,
) {
analyticsEventHandler.send(event = eventCreator(AnalyticsParam.Status.Error))
stateHolder.update(
transformer = DisableActionTransformer(userWalletId = userWalletId, actionClass = actionKClass),
)
walletEventSender.send(event = WalletEvent.ShowAlert(state = alertState))
}

View file

@ -88,7 +88,7 @@ markdownComposeView = "0.5.4"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "release-app_5.19-881"
tangemBlockchainSdk = "release-app_5.19-882"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "release-app_5.19-414"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -21,13 +21,15 @@ internal class DefaultBlockchainDataStorage(
return appPreferencesStore.getSyncOrNull(key = stringPreferencesKey(name = key))
}
override suspend fun remove(key: String) {
TODO("Not yet implemented")
}
override suspend fun store(key: String, value: String) {
appPreferencesStore.edit {
it[stringPreferencesKey(key)] = value
}
}
override suspend fun remove(key: String) {
appPreferencesStore.edit {
it.remove(stringPreferencesKey(key))
}
}
}