Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-07 13:52:30 +02:00
parent 879e3e3de0
commit 75c70a3d77
4 changed files with 135 additions and 318 deletions

View file

@ -1,6 +1,10 @@
package com.tangem.feature.swap.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.FeeType
private const val SWAP_CATEGORY = "Swap"
sealed class SwapEvents(
event: String,
@ -13,14 +17,17 @@ sealed class SwapEvents(
)
object SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")
object ChooseTokenScreenOpened : SwapEvents(event = "Choose Token Screen Opened")
data class SearchTokenClicked(val currencySymbol: String?) : SwapEvents(
event = "Searched Token Clicked",
data class ChooseTokenScreenOpened(val availableTokens: Boolean) : SwapEvents(
event = "Choose Token Screen Opened",
params = mapOf("Available tokens" to if (availableTokens) "Yes" else "No"),
)
data class ChooseTokenScreenResult(val tokenChosen: Boolean, val token: String? = null) : SwapEvents(
event = "Choose Token Screen Result",
params = buildMap {
if (currencySymbol != null) {
put("Token", currencySymbol)
}
put("Token Chosen", if (tokenChosen) "Yes" else "No")
token?.let { put("Token", it) }
},
)
@ -37,8 +44,61 @@ sealed class SwapEvents(
)
object ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel")
object ButtonSwipeClicked : SwapEvents(event = "Button - Swipe")
object SwapInProgressScreen : SwapEvents(event = "Swap in Progress Screen Opened")
}
private const val SWAP_CATEGORY = "Swap"
object ButtonSwipeClicked : SwapEvents(event = "Button - Swipe")
data class SwapInProgressScreen(
val provider: SwapProvider,
val commission: FeeType, // Market / Fast
val sendToken: String,
val receiveToken: String,
) : SwapEvents(
event = "Swap in Progress Screen Opened",
params = mapOf(
"Provider" to provider.name,
"Commission" to if (commission == FeeType.NORMAL) "Market" else "Fast",
"Send Token" to sendToken,
"Receive Token" to receiveToken,
),
)
object ProviderClicked : SwapEvents("Provider Clicked")
data class ProviderChosen(val provider: SwapProvider) : SwapEvents(
event = "Provider Chosen",
params = mapOf("Provider" to provider.name),
)
data class ButtonStatus(val token: String) : SwapEvents(
event = "Button - Status",
params = mapOf("Token" to token),
)
data class ButtonExplore(val token: String) : SwapEvents(
event = "Button - Explore",
params = mapOf("Token" to token),
)
object NoticeNoAvailableTokensToSwap : SwapEvents("Notice - No Available Tokens To Swap")
data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents(
event = "Notice - Not Enough Fee",
params = mapOf(
"Token" to token,
"Blockchain" to blockchain,
),
)
data class NoticeProviderError(
val token: String,
val provider: SwapProvider,
val errorCode: Int,
) : SwapEvents(
event = "Notice - Express Error",
params = mapOf(
"Token" to token,
"Provider" to provider.name,
"Error code" to errorCode.toString(),
),
)
}

View file

@ -1,297 +0,0 @@
package com.tangem.feature.swap.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import com.tangem.core.ui.components.*
// @Composable
// fun SwapPermissionBottomSheetContent(data: SwapPermissionState.ReadyForRequest, onCancel: () -> Unit) {
// var isPermissionAlertShow by remember { mutableStateOf(false) }
// Column(
// modifier = Modifier
// .background(color = TangemTheme.colors.background.primary)
// .fillMaxWidth()
// .padding(horizontal = TangemTheme.dimens.spacing16),
// horizontalAlignment = Alignment.CenterHorizontally,
// ) {
// Hand()
//
// SpacerH10()
//
// Box(modifier = Modifier.fillMaxWidth()) {
// Text(
// modifier = Modifier.align(Alignment.Center),
// text = stringResource(id = R.string.swapping_permission_header),
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.subtitle1,
// )
// IconButton(
// modifier = Modifier.align(Alignment.CenterEnd),
// onClick = { isPermissionAlertShow = true },
// ) {
// Icon(
// painter = painterResource(id = R.drawable.ic_question_24),
// contentDescription = null,
// )
// }
// }
//
// SpacerH10()
//
// Text(
// text = stringResource(
// id = R.string.swapping_permission_subheader,
// data.currency,
// ),
// color = TangemTheme.colors.text.secondary,
// style = TangemTheme.typography.body2,
// textAlign = TextAlign.Center,
// modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing8),
// )
//
// SpacerH16()
//
// ApprovalBottomSheetInfo(data)
//
// SpacerH28()
//
// PrimaryButtonIconEnd(
// text = stringResource(id = R.string.swapping_permission_buttons_approve),
// iconResId = R.drawable.ic_tangem_24,
// modifier = Modifier.fillMaxWidth(),
// onClick = data.approveButton.onClick,
// )
//
// SpacerH12()
//
// SecondaryButton(
// text = stringResource(id = R.string.common_cancel),
// modifier = Modifier.fillMaxWidth(),
// onClick = {
// onCancel()
// },
// )
//
// SpacerH16()
//
// // region dialog
// if (isPermissionAlertShow) {
// BasicDialog(
// message = stringResource(id = R.string.swapping_approve_information_text),
// title = stringResource(id = R.string.swapping_approve_information_title),
// confirmButton = DialogButton { isPermissionAlertShow = false },
// onDismissDialog = {},
// )
// }
// }
// }
// @Composable
// private fun ApprovalBottomSheetInfo(data: SwapPermissionState.ReadyForRequest) {
// Column(
// modifier = Modifier
// .background(color = TangemTheme.colors.background.primary)
// .fillMaxWidth(),
// horizontalAlignment = Alignment.CenterHorizontally,
// ) {
// AmountItem(
// currency = data.currency,
// approveType = data.approveType,
// onChangeApproveType = data.onChangeApproveType,
// approveItems = data.approveItems,
// )
// SubtitleItem(
// subtitle = stringResource(id = R.string.swapping_permission_policy_type_footer),
// modifier = Modifier.fillMaxWidth(),
// )
// SpacerH24()
// DividerBottomSheet()
// FeeItem(fee = data.fee.resolveReference())
// SubtitleItem(
// subtitle = stringResource(id = R.string.swapping_permission_fee_footer),
// modifier = Modifier.fillMaxWidth(),
// )
// }
// }
//
// @Composable
// private fun DividerBottomSheet() {
// Divider(
// color = TangemTheme.colors.stroke.primary,
// thickness = TangemTheme.dimens.size0_5,
// )
// }
//
// @Composable
// private fun InformationItem(subtitle: String, value: String) {
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .padding(vertical = TangemTheme.dimens.spacing16),
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// ) {
// Text(
// text = subtitle,
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.subtitle1,
// maxLines = 1,
// )
//
// MiddleEllipsisText(
// text = value,
// color = TangemTheme.colors.text.tertiary,
// style = TangemTheme.typography.body2,
// modifier = Modifier.padding(start = TangemTheme.dimens.spacing16),
// )
// }
// }
//
// @Composable
// private fun AmountItem(
// currency: String,
// approveType: ApproveType,
// approveItems: ImmutableList<ApproveType>,
// onChangeApproveType: (ApproveType) -> Unit,
// ) {
// var isExpandSelector by remember {
// mutableStateOf(false)
// }
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .padding(vertical = TangemTheme.dimens.spacing16),
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// ) {
// Text(
// text = stringResource(id = R.string.swapping_permission_rows_amount, currency),
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.subtitle1,
// maxLines = 1,
// )
// Box {
// SelectorItem(
// getTitleForApproveType(approveType = approveType),
// ) {
// isExpandSelector = true
// }
// DropdownSelector(
// isExpanded = isExpandSelector,
// onDismiss = { isExpandSelector = false },
// onItemClick = { approveType ->
// isExpandSelector = false
// onChangeApproveType.invoke(approveType)
// },
// items = approveItems,
// )
// }
// }
// }
//
// @Composable
// private fun SelectorItem(title: String, onClick: () -> Unit) {
// Row(
// modifier = Modifier.clickable { onClick() },
// ) {
// Text(
// text = title,
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.body1,
// maxLines = 1,
// )
// Icon(
// painter = painterResource(id = R.drawable.ic_chevron_24),
// tint = TangemTheme.colors.icon.primary1,
// contentDescription = null,
// )
// }
// }
//
// @Composable
// private fun DropdownSelector(
// isExpanded: Boolean,
// onDismiss: () -> Unit,
// onItemClick: (ApproveType) -> Unit,
// items: ImmutableList<ApproveType>,
// ) {
// DropdownMenu(
// expanded = isExpanded,
// onDismissRequest = onDismiss,
// modifier = Modifier
// .wrapContentSize()
// .background(TangemTheme.colors.background.secondary),
// ) {
// items.forEach { item ->
// DropdownMenuItem(
// onClick = {
// onItemClick.invoke(item)
// },
// ) {
// Text(
// text = getTitleForApproveType(approveType = item),
// color = TangemTheme.colors.text.primary1,
// style = TangemTheme.typography.body1,
// maxLines = 1,
// )
// }
// }
// }
// }
//
// @Composable
// private fun FeeItem(fee: String) {
// InformationItem(
// subtitle = stringResource(id = R.string.send_fee_label),
// value = fee,
// )
// }
//
// @Composable
// private fun SubtitleItem(subtitle: String, modifier: Modifier = Modifier) {
// Text(
// modifier = modifier,
// text = subtitle,
// color = TangemTheme.colors.text.secondary,
// style = TangemTheme.typography.body2,
// )
// }
//
// @Composable
// private fun getTitleForApproveType(approveType: ApproveType): String = when (approveType) {
// ApproveType.LIMITED -> stringResource(id = R.string.swapping_permission_current_transaction)
// ApproveType.UNLIMITED -> stringResource(id = R.string.swapping_permission_unlimited)
// }
//
// // region preview
//
// @Preview
// @Composable
// private fun Preview_AgreementBottomSheet_InLightTheme() {
// TangemTheme(isDark = false) {
// SwapPermissionBottomSheetContent(data = previewData) {}
// }
// }
//
// @Preview
// @Composable
// private fun Preview_AgreementBottomSheet_InDarkTheme() {
// TangemTheme(isDark = true) {
// SwapPermissionBottomSheetContent(data = previewData) {}
// }
// }
//
// private val previewData = SwapPermissionState.ReadyForRequest(
// currency = "DAI",
// amount = "∞",
// walletAddress = "",
// spenderAddress = "",
// fee = TextReference.Str("2,14$"),
// approveType = ApproveType.UNLIMITED,
// approveButton = ApprovePermissionButton(true) {},
// cancelButton = CancelPermissionButton(true),
// onChangeApproveType = { ApproveType.UNLIMITED },
// )
//endregion preview

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -33,6 +34,8 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) {
BackHandler(onBack = onBack)
Scaffold(
modifier = Modifier
.systemBarsPadding()

View file

@ -18,15 +18,13 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.domain.PermissionOptions
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.ApproveType
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.toDomainApproveType
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.presentation.SwapFragment
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.router.SwapRouter
@ -140,7 +138,7 @@ internal class SwapViewModel @Inject constructor(
uiState = uiState.copy(
onSelectTokenClick = {
router.openScreen(SwapNavScreen.SelectToken)
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened)
sendSelectTokenScreenOpenedEvent()
},
onSuccess = {
router.openScreen(SwapNavScreen.Success)
@ -148,9 +146,14 @@ internal class SwapViewModel @Inject constructor(
)
}
@Suppress("UnusedPrivateMember")
private fun sendSelectTokenScreenOpenedEvent() {
val isAnyAvailableTokensTo = dataState.tokensDataState?.toGroup?.available?.isNotEmpty() ?: false
val isAnyAvailableTokensFrom = dataState.tokensDataState?.fromGroup?.available?.isNotEmpty() ?: false
val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(availableTokens = isAnyAvailableTokens))
}
private fun initTokens() {
// new flow
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
swapInteractor.getTokensDataState(initialCryptoCurrency)
@ -190,6 +193,7 @@ internal class SwapViewModel @Inject constructor(
tokensDataState = state,
)
if (selectedCurrency == null) {
analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap)
uiState = stateBuilder.createNoAvailableTokensToSwapState(
uiStateHolder = uiState,
fromToken = fromCurrencyStatus,
@ -317,6 +321,14 @@ internal class SwapViewModel @Inject constructor(
isManyProviders = dataState.lastLoadedSwapStates.size > 1,
selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
)
if (uiState.warnings.any { it is SwapWarning.UnableToCoverFeeWarning }) {
analyticsEventHandler.send(
SwapEvents.NoticeNotEnoughFee(
token = initialCryptoCurrency.symbol,
blockchain = fromToken.currency.network.name,
),
)
}
}
is SwapState.EmptyAmountState -> {
uiState = stateBuilder.createQuotesEmptyAmountState(
@ -332,10 +344,21 @@ internal class SwapViewModel @Inject constructor(
toToken = dataState.toCryptoCurrency,
dataError = state.error,
)
sendErrorAnalyticsEvent(state.error, provider)
}
}
}
private fun sendErrorAnalyticsEvent(error: DataError, provider: SwapProvider) {
analyticsEventHandler.send(
SwapEvents.NoticeProviderError(
token = initialCryptoCurrency.symbol,
provider = provider,
errorCode = error.code,
),
)
}
private fun updateLoadedQuotes(state: Map<SwapProvider, SwapState>): Pair<SwapProvider, SwapState> {
val nonEmptyStates = state.filter { it.value !is SwapState.EmptyAmountState }
val selectedSwapProvider = if (nonEmptyStates.isNotEmpty()) {
@ -438,15 +461,22 @@ internal class SwapViewModel @Inject constructor(
if (txHash.isNotEmpty()) {
swapRouter.openUrl(url)
}
analyticsEventHandler.send(
event = SwapEvents.ButtonExplore(initialCryptoCurrency.symbol),
)
},
onStatusClick = {
val txExternalUrl = it.txExternalUrl
if (!txExternalUrl.isNullOrBlank()) {
swapRouter.openUrl(txExternalUrl)
analyticsEventHandler.send(
event = SwapEvents.ButtonStatus(initialCryptoCurrency.symbol),
)
}
},
)
analyticsEventHandler.send(SwapEvents.SwapInProgressScreen)
sendSuccessEvent()
swapRouter.openScreen(SwapNavScreen.Success)
}
is TxState.UserCancelled -> {
@ -468,6 +498,22 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun sendSuccessEvent() {
val provider = dataState.selectedProvider ?: return
val fee = dataState.selectedFee?.feeType ?: return
val sendToken = dataState.fromCryptoCurrency?.currency?.symbol ?: return
val toToken = dataState.toCryptoCurrency?.currency?.symbol ?: return
analyticsEventHandler.send(
SwapEvents.SwapInProgressScreen(
provider = provider,
commission = fee,
sendToken = sendToken,
receiveToken = toToken,
),
)
}
private fun givePermissionsToSwap() {
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
@ -563,9 +609,9 @@ internal class SwapViewModel @Inject constructor(
it.currencyStatus.currency.id.value == id
}
}
analyticsEventHandler.send(
event = SwapEvents.SearchTokenClicked(currencySymbol = foundToken?.currencyStatus?.currency?.symbol),
)
foundToken?.currencyStatus?.currency?.symbol?.let {
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it))
}
if (foundToken != null) {
val fromToken: CryptoCurrencyStatus
@ -702,6 +748,9 @@ internal class SwapViewModel @Inject constructor(
if (bottomSheet != null && bottomSheet.isShow) {
uiState = stateBuilder.dismissBottomSheet(uiState)
} else {
if (swapRouter.currentScreen == SwapNavScreen.SelectToken) {
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = false))
}
swapRouter.back()
}
onSearchEntered("")
@ -752,6 +801,7 @@ internal class SwapViewModel @Inject constructor(
}
},
onProviderClick = { providerId ->
analyticsEventHandler.send(SwapEvents.ProviderClicked)
val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(states)
val unavailableProviders = getUnavailableProvidersFor(dataState.lastLoadedSwapStates)
@ -768,6 +818,7 @@ internal class SwapViewModel @Inject constructor(
val swapState = dataState.lastLoadedSwapStates[provider]
val fromToken = dataState.fromCryptoCurrency
if (provider != null && swapState != null && fromToken != null) {
analyticsEventHandler.send(SwapEvents.ProviderChosen(provider))
uiState = stateBuilder.updateSelectedProvider(uiState, provider.providerId)
setupLoadedState(
provider = provider,