Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-04 16:31:59 +02:00
parent 540a938993
commit afb82a03f7
15 changed files with 270 additions and 41 deletions

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
@JsonClass(generateAdapter = true)
data class ExchangeStatusResponse(
@ -26,6 +27,12 @@ data class ExchangeStatusResponse(
@Json(name = "refundContractAddress")
val refundContractAddress: String? = null,
@Json(name = "createdAt")
val createdAt: DateTime? = null,
@Json(name = "averageDuration")
val averageDuration: Int? = null,
)
@JsonClass(generateAdapter = false)

View file

@ -26,17 +26,34 @@ class TokenExchangeAnalyticsEvent(
)
class GoToProviderStatus(token: String) : TokenScreenAnalyticsEvent(
event = "Button - Go To Provider",
event = BUTTON_GO_TO_PROVIDER,
params = mapOf(TOKEN_PARAM to token, PLACE to "Status"),
)
class GoToProviderKYC(token: String) : TokenScreenAnalyticsEvent(
event = "Button - Go To Provider",
event = BUTTON_GO_TO_PROVIDER,
params = mapOf(TOKEN_PARAM to token, PLACE to "KYC"),
)
class GoToProviderFail(token: String) : TokenScreenAnalyticsEvent(
event = "Button - Go To Provider",
event = BUTTON_GO_TO_PROVIDER,
params = mapOf(TOKEN_PARAM to token, PLACE to "Fail"),
)
class GoToProviderLongTime(token: String) : TokenScreenAnalyticsEvent(
event = BUTTON_GO_TO_PROVIDER,
params = mapOf(TOKEN_PARAM to token, PLACE to "LongTime"),
)
class LongTimeTransaction(token: String, provider: String) : TokenScreenAnalyticsEvent(
event = "Notice - Long Time Transaction",
params = mapOf(
TOKEN_PARAM to token,
PROVIDER to provider,
),
)
private companion object {
const val BUTTON_GO_TO_PROVIDER = "Button - Go To Provider"
}
}

View file

@ -49,6 +49,7 @@ dependencies {
/** Others */
implementation(deps.timber)
implementation(deps.jodatime)
/** DI */
implementation(deps.hilt.android)

View file

@ -17,6 +17,8 @@ internal class ExchangeStatusConverter : Converter<ExchangeStatusResponse, Excha
txExternalId = value.externalTxId,
refundNetwork = value.refundNetwork,
refundContractAddress = value.refundContractAddress,
createdAt = value.createdAt,
averageDuration = value.averageDuration,
)
}
}

View file

@ -22,6 +22,7 @@ dependencies {
implementation(deps.kotlin.serialization)
implementation(deps.arrow.core)
implementation(deps.moshi.kotlin)
implementation(deps.jodatime)
ksp(deps.moshi.kotlin.codegen)
}

View file

@ -4,6 +4,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.tokens.model.CryptoCurrency
import org.joda.time.DateTime
@JsonClass(generateAdapter = true)
data class ExchangeStatusModel(
@ -25,7 +26,18 @@ data class ExchangeStatusModel(
val refundTokensResponse: UserTokensResponse.Token? = null,
@Json(ignore = true)
val refundCurrency: CryptoCurrency? = null,
)
@Json(name = "createdAt")
val createdAt: DateTime? = null,
@Json(name = "averageDuration")
val averageDuration: Int? = null,
) {
val hasLongTime: Boolean
get() = if (createdAt != null && averageDuration != null) {
DateTime.now().minusSeconds(averageDuration * 5) > createdAt
} else {
false
}
}
@JsonClass(generateAdapter = false)
enum class ExchangeStatus {

View file

@ -1006,6 +1006,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
timestamp: Long,
txExternalUrl: String? = null,
txExternalId: String? = null,
averageDuration: Int? = null,
) {
swapTransactionRepository.storeTransaction(
userWalletId = userWalletId,
@ -1023,6 +1024,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
txId = swapDataModel.transaction.txId,
txExternalUrl = txExternalUrl,
txExternalId = txExternalId,
averageDuration = averageDuration,
),
),
)

View file

@ -15,9 +15,9 @@ import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.tokendetails.impl.R
@Immutable
internal sealed interface ExchangeStatusNotifications {
internal sealed interface ExchangeStatusNotification {
sealed class CommonNotification(val config: NotificationConfig) : ExchangeStatusNotifications
sealed class CommonNotification(val config: NotificationConfig) : ExchangeStatusNotification
@Deprecated("Use one in ExpressNotificationsUM")
data class NeedVerification(val onGoToProviderClick: () -> Unit) : CommonNotification(
@ -45,11 +45,23 @@ internal sealed interface ExchangeStatusNotifications {
),
)
data class LongTimeExchange(val onGoToProviderClick: () -> Unit) : CommonNotification(
config = NotificationConfig(
title = resourceReference(R.string.express_exchange_notification_long_transaction_time_title),
subtitle = resourceReference(R.string.express_exchange_notification_long_transaction_time_text),
iconResId = R.drawable.ic_alert_triangle_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_go_to_provider),
onClick = onGoToProviderClick,
),
),
)
data class TokenRefunded(
val cryptoCurrency: CryptoCurrency,
val onReadMoreClick: () -> Unit,
val onGoToTokenClick: () -> Unit,
) : ExchangeStatusNotifications {
) : ExchangeStatusNotification {
val config = CurrencyNotificationConfig(
title = resourceReference(

View file

@ -5,7 +5,7 @@ import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotifications
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
import kotlinx.collections.immutable.ImmutableList
internal data class ExchangeUM(
@ -13,8 +13,9 @@ internal data class ExchangeUM(
val provider: SwapProvider,
val activeStatus: ExchangeStatus?,
val statuses: ImmutableList<ExchangeStatusState>,
val notification: ExchangeStatusNotifications? = null,
val notification: ExchangeStatusNotification?,
val showProviderLink: Boolean,
val fromCryptoCurrency: CryptoCurrency,
val toCryptoCurrency: CryptoCurrency,
val hasLongTime: Boolean,
) : ExpressTransactionStateUM

View file

@ -24,7 +24,7 @@ import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isF
import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel
import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel
import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotifications
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
@ -82,17 +82,18 @@ internal class TokenDetailsSwapTransactionsStateConverter(
}
}
val statusModel = transaction.status
val notifications = getNotification(
val notification = getNotification(
status = statusModel?.status,
txUrl = statusModel?.txExternalUrl,
refundToken = statusModel?.refundCurrency,
hasLongTime = statusModel?.hasLongTime,
)
val showProviderLink = getShowProviderLink(notifications, transaction.status)
val showProviderLink = getShowProviderLink(notification, transaction.status)
result.add(
ExchangeUM(
provider = transaction.provider,
statuses = getStatuses(statusModel?.status),
notification = notifications,
notification = notification,
activeStatus = statusModel?.status,
showProviderLink = showProviderLink,
fromCryptoCurrency = fromCryptoCurrency,
@ -104,6 +105,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
toFiatAmount,
fromFiatAmount,
),
hasLongTime = transaction.status?.hasLongTime ?: false,
),
)
}
@ -111,17 +113,22 @@ internal class TokenDetailsSwapTransactionsStateConverter(
return result.toPersistentList()
}
fun updateTxStatus(tx: ExchangeUM, statusModel: ExchangeStatusModel?): ExchangeUM {
if (statusModel == null || tx.activeStatus == statusModel.status) {
fun updateTxStatus(tx: ExchangeUM, statusModel: ExchangeStatusModel): ExchangeUM {
if (tx.activeStatus == statusModel.status && tx.hasLongTime == statusModel.hasLongTime) {
Timber.e("UpdateTxStatus isn't required. Current status isn't changed")
return tx
}
val hasFailed = statusModel.status.isFailed()
val notifications = getNotification(statusModel.status, statusModel.txExternalUrl, statusModel.refundCurrency)
val showProviderLink = getShowProviderLink(notifications, statusModel)
val notification = getNotification(
status = statusModel.status,
txUrl = statusModel.txExternalUrl,
refundToken = statusModel.refundCurrency,
hasLongTime = statusModel.hasLongTime,
)
val showProviderLink = getShowProviderLink(notification, statusModel)
return tx.copy(
activeStatus = statusModel.status,
notification = notifications,
notification = notification,
statuses = getStatuses(statusModel.status, hasFailed),
showProviderLink = showProviderLink,
info = tx.info.copy(txExternalUrl = statusModel.txExternalUrl),
@ -184,39 +191,46 @@ internal class TokenDetailsSwapTransactionsStateConverter(
status: ExchangeStatus?,
txUrl: String?,
refundToken: CryptoCurrency?,
): ExchangeStatusNotifications? {
return when (status) {
ExchangeStatus.Failed,
ExchangeStatus.TxFailed,
-> {
hasLongTime: Boolean?,
): ExchangeStatusNotification? {
return when {
status == ExchangeStatus.Failed || status == ExchangeStatus.TxFailed -> {
if (txUrl == null) return null
ExchangeStatusNotifications.Failed {
ExchangeStatusNotification.Failed {
analyticsEventsHandler.send(
TokenExchangeAnalyticsEvent.GoToProviderFail(cryptoCurrency.symbol),
)
clickIntents.onGoToProviderClick(txUrl)
}
}
ExchangeStatus.Verifying -> {
status == ExchangeStatus.Verifying -> {
if (txUrl == null) return null
ExchangeStatusNotifications.NeedVerification {
ExchangeStatusNotification.NeedVerification {
analyticsEventsHandler.send(
TokenExchangeAnalyticsEvent.GoToProviderKYC(cryptoCurrency.symbol),
)
clickIntents.onGoToProviderClick(txUrl)
}
}
ExchangeStatus.Refunded -> {
status == ExchangeStatus.Refunded -> {
if (refundToken == null) {
null
} else {
ExchangeStatusNotifications.TokenRefunded(
ExchangeStatusNotification.TokenRefunded(
cryptoCurrency = refundToken,
onReadMoreClick = { clickIntents.onOpenUrlClick(url = getAboutCrossChainBridgesLink()) },
onGoToTokenClick = { clickIntents.onGoToRefundedTokenClick(refundToken) },
)
}
}
status?.isTerminal == false && txUrl != null && hasLongTime == true -> {
ExchangeStatusNotification.LongTimeExchange {
analyticsEventsHandler.send(
event = TokenExchangeAnalyticsEvent.GoToProviderLongTime(cryptoCurrency.symbol),
)
clickIntents.onGoToProviderClick(txUrl)
}
}
else -> null
}
}
@ -239,7 +253,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
statuses = persistentListOf(),
)
private fun getShowProviderLink(notifications: ExchangeStatusNotifications?, status: ExchangeStatusModel?) =
private fun getShowProviderLink(notifications: ExchangeStatusNotification?, status: ExchangeStatusModel?) =
notifications == null && status?.txExternalUrl != null && status.status != ExchangeStatus.Cancelled
private fun getStatuses(status: ExchangeStatus?, hasFailed: Boolean = false): ImmutableList<ExchangeStatusState> {

View file

@ -101,10 +101,14 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
} else {
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider)
swapTransactionsStateConverter.updateTxStatus(
tx = swapTx,
statusModel = statusModel,
)
if (statusModel != null) {
swapTransactionsStateConverter.updateTxStatus(
tx = swapTx,
statusModel = statusModel,
)
} else {
swapTx
}
}
}

View file

@ -12,6 +12,7 @@ import com.tangem.domain.tokens.model.analytics.TokenOnrampAnalyticsEvent
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.utils.Provider
@ -110,9 +111,7 @@ internal class ExpressStatusFactory @AssistedInject constructor(
return state.copy(
expressTxs = expressTxs,
expressTxsToDisplay = expressTxsToDisplay,
bottomSheetConfig = currentTx?.let(
::updateStateWithExpressStatusBottomSheet,
) ?: config,
bottomSheetConfig = currentTx?.let(::updateStateWithExpressStatusBottomSheet) ?: config,
)
}
@ -146,6 +145,12 @@ internal class ExpressStatusFactory @AssistedInject constructor(
val state = currentStateProvider()
val bottomSheetConfig = state.bottomSheetConfig
val currentConfig = bottomSheetConfig?.content as? ExpressStatusBottomSheetConfig ?: return bottomSheetConfig
sendLongTimeExchangeNotificationShowEvent(
expressState = expressState,
currentStateNotification = (currentConfig.value as? ExchangeUM)?.notification,
)
return bottomSheetConfig.copy(
content = if (currentConfig.value != expressState) {
ExpressStatusBottomSheetConfig(expressState)
@ -167,6 +172,24 @@ internal class ExpressStatusFactory @AssistedInject constructor(
}
}
private fun sendLongTimeExchangeNotificationShowEvent(
expressState: ExpressTransactionStateUM,
currentStateNotification: ExchangeStatusNotification?,
) {
val newState = expressState as? ExchangeUM
val newStateNotification = newState?.notification
if (currentStateNotification !is ExchangeStatusNotification.LongTimeExchange &&
newStateNotification is ExchangeStatusNotification.LongTimeExchange
) {
analyticsEventsHandler.send(
TokenExchangeAnalyticsEvent.LongTimeTransaction(
token = cryptoCurrency.symbol,
provider = newState.provider.name,
),
)
}
}
@AssistedFactory
interface Factory {
@Suppress("LongParameterList")

View file

@ -1,12 +1,16 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.common.ui.expressStatus.OnrampStatusBottomSheetContent
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.common.ui.expressStatus.state.*
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.exchange.ExchangeStatusBottomSheetContent
@ -21,4 +25,21 @@ internal fun ExpressStatusBottomSheet(config: TangemBottomSheetConfig) {
is ExchangeUM -> ExchangeStatusBottomSheetContent(state)
}
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 720)
@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewExpressStatusBottomSheet(
@PreviewParameter(ExpressStatusBottomSheetStateProvider::class) param: ExpressStatusBottomSheetConfig,
) {
TangemThemePreview {
ExpressStatusBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = param,
),
)
}
}

View file

@ -0,0 +1,111 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.common.ui.expressStatus.state.*
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrency.ID
import com.tangem.domain.tokens.model.Network
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider<ExpressStatusBottomSheetConfig> {
override val values: Sequence<ExpressStatusBottomSheetConfig>
get() = sequenceOf(
ExpressStatusBottomSheetConfig(
ExchangeUM(
info = ExpressTransactionStateInfoUM(
title = TextReference.Str("Transaction Status"),
status = ExpressStatusUM(
title = TextReference.Str("Status Details"),
link = ExpressLinkUM.Empty,
statuses = persistentListOf(
ExpressStatusItemUM(TextReference.Str("Created"), ExpressStatusItemState.Active),
ExpressStatusItemUM(TextReference.Str("Exchanging"), ExpressStatusItemState.Active),
ExpressStatusItemUM(TextReference.Str("Done"), ExpressStatusItemState.Active),
),
),
notification = null,
txId = "123456",
txExternalId = "78910",
txExternalUrl = "https://example.com/tx/78910",
timestamp = System.currentTimeMillis(),
timestampFormatted = TextReference.Str("Just now"),
onGoToProviderClick = {},
onClick = {},
onDisposeExpressStatus = {},
iconState = ExpressTransactionStateIconUM.None,
toAmount = TextReference.Str("0.1 BTC"),
toFiatAmount = TextReference.Str("$5000"),
toAmountSymbol = "BTC",
toCurrencyIcon = CurrencyIconState.Empty(),
fromAmount = TextReference.Str("5000 USDT"),
fromFiatAmount = TextReference.Str("$5000"),
fromAmountSymbol = "USDT",
fromCurrencyIcon = CurrencyIconState.Empty(),
),
provider = SwapProvider(
providerId = "1",
rateTypes = emptyList(),
name = "Provider",
type = ExchangeProviderType.DEX,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
slippage = BigDecimal.ZERO,
),
activeStatus = null,
statuses = persistentListOf(
ExchangeStatusState(
status = ExchangeStatus.Exchanging,
text = stringReference("Exchanging"),
isActive = true,
isDone = false,
),
),
notification = ExchangeStatusNotification.LongTimeExchange {},
showProviderLink = false,
fromCryptoCurrency = token,
toCryptoCurrency = token,
hasLongTime = true,
),
),
)
private val token
get() = CryptoCurrency.Coin(
id = ID(
ID.Prefix.COIN_PREFIX,
ID.Body.NetworkId(network.id.value),
ID.Suffix.RawID("token1"),
),
network = network,
name = "Token 1",
symbol = "T1",
decimals = 8,
iconUrl = null,
isCustom = false,
)
private val network = Network(
id = Network.ID("network1"),
name = "Network One",
isTestnet = false,
standardType = Network.StandardType.ERC20,
backendId = "network1",
currencySymbol = "ETH",
derivationPath = Network.DerivationPath.None,
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
)
}

View file

@ -24,7 +24,7 @@ import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotifications
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
@Composable
@ -88,14 +88,15 @@ internal fun ExchangeStatusBottomSheetContent(state: ExchangeUM) {
}
@Composable
private fun Notification(state: ExchangeStatusNotifications, activeStatus: ExchangeStatus?) {
private fun Notification(state: ExchangeStatusNotification, activeStatus: ExchangeStatus?) {
AnimatedContent(
targetState = state,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
label = "Exchange Status Notification Change",
contentKey = { it::class.java },
) { notification ->
when (notification) {
is ExchangeStatusNotifications.CommonNotification -> {
is ExchangeStatusNotification.CommonNotification -> {
com.tangem.core.ui.components.notifications.Notification(
config = notification.config,
iconTint = when {
@ -106,7 +107,7 @@ private fun Notification(state: ExchangeStatusNotifications, activeStatus: Excha
containerColor = TangemTheme.colors.background.action,
)
}
is ExchangeStatusNotifications.TokenRefunded -> {
is ExchangeStatusNotification.TokenRefunded -> {
CurrencyNotification(
config = notification.config,
containerColor = TangemTheme.colors.background.action,