Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-09 15:25:00 +05:00
parent 5421e5eb69
commit 165bf516e6
256 changed files with 1098 additions and 1035 deletions

View file

@ -0,0 +1,19 @@
package com.tangem.features.send.api
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
interface FeeSelectorBlockComponent : ComposableContentComponent {
fun updateState(feeSelectorUM: FeeSelectorUM)
interface Factory {
fun create(
context: AppComponentContext,
params: FeeSelectorParams.FeeSelectorBlockParams,
onResult: (feeSelectorUM: FeeSelectorUM) -> Unit,
): FeeSelectorBlockComponent
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.send.api
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.send.api.params.FeeSelectorParams
interface FeeSelectorComponent : ComposableBottomSheetComponent {
interface Factory {
fun create(
context: AppComponentContext,
params: FeeSelectorParams.FeeSelectorDetailsParams,
onDismiss: () -> Unit,
): FeeSelectorComponent
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.send.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.models.wallet.UserWalletId
interface NFTSendComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val nftAsset: NFTAsset,
val nftCollectionName: String,
)
interface Factory : ComponentFactory<Params, NFTSendComponent>
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.send.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableDialogComponent
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
interface NetworkSelectionComponent : ComposableDialogComponent {
data class Params(
val address: String,
val amount: BigDecimal?,
val memo: String?,
val walletGroups: List<WalletGroup>,
val onTokenSelected: (UserWalletId, CryptoCurrency) -> Unit,
val onDismiss: () -> Unit,
) {
data class WalletGroup(
val userWalletId: UserWalletId,
val walletName: String,
val accounts: List<AccountGroup>,
)
data class AccountGroup(
val accountId: AccountId,
val accountName: AccountName,
val currencies: List<CryptoCurrency>,
val hiddenTokensCount: Int = 0,
)
}
interface Factory : ComponentFactory<Params, NetworkSelectionComponent>
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.send.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
interface SendComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val transactionId: String? = null,
val amount: String? = null,
val tag: String? = null,
val destinationAddress: String? = null,
val entryType: EntryType = EntryType.Manual,
val callback: ModelCallback? = null,
)
enum class EntryType {
Manual,
QR,
}
interface Factory : ComponentFactory<Params, SendComponent>
interface ModelCallback {
fun onConvertToAnotherToken(lastAmount: String, isEnterInFiatSelected: Boolean)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.send.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
interface SendEntryPointComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
)
interface Factory : ComponentFactory<Params, SendEntryPointComponent>
}

View file

@ -0,0 +1,3 @@
package com.tangem.features.send.api
interface SendFeatureToggles

View file

@ -0,0 +1,59 @@
package com.tangem.features.send.api
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.GetFeeError
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.StateFlow
import java.math.BigDecimal
interface SendNotificationsComponent {
val state: StateFlow<ImmutableList<NotificationUM>>
fun LazyListScope.content(
state: ImmutableList<NotificationUM>,
modifier: Modifier = Modifier,
hasPaddingAbove: Boolean = false,
isClickDisabled: Boolean = false,
)
data class Params(
val analyticsCategoryName: String,
val userWalletId: UserWalletId,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrency: AppCurrency,
val notificationData: NotificationData,
val callback: ModelCallback,
) {
data class NotificationData(
val destinationAddress: String?,
val memo: String?,
val amountValue: BigDecimal,
val reduceAmountBy: BigDecimal,
val isIgnoreReduce: Boolean,
val fee: Fee?,
val feeError: GetFeeError?,
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val isReduceAmountAvailable: Boolean = true,
)
}
interface Factory : ComponentFactory<Params, SendNotificationsComponent>
interface ModelCallback {
fun onFeeReload()
fun onAmountReduceTo(reduceTo: BigDecimal) {}
fun onAmountReduceBy(reduceBy: BigDecimal, reduceByDiff: BigDecimal) {}
fun onAmountIgnore() {}
}
}

View file

@ -0,0 +1,242 @@
package com.tangem.features.send.api.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
/**
* Send analytics
*/
sealed class CommonSendAnalyticEvents(
category: String,
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = category, event = event, params = params) {
/** Recipient address screen opened */
data class AddressScreenOpened(
val categoryName: String,
val source: CommonSendSource,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Address Screen Opened",
params = mapOf(
SOURCE to source.analyticsName,
),
), AppsFlyerIncludedEvent
/** Amount screen opened */
data class AmountScreenOpened(
val categoryName: String,
val source: CommonSendSource,
val type: SendEntryType = SendEntryType.Manual,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Amount Screen Opened",
params = mapOf(
SOURCE to source.analyticsName,
TYPE to type.analyticsName,
),
), AppsFlyerIncludedEvent
/** Fee screen opened */
data class FeeScreenOpened(
val categoryName: String,
val source: CommonSendSource,
val blockchain: String,
val token: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Fee Screen Opened",
params = mapOf(
SOURCE to source.analyticsName,
BLOCKCHAIN to blockchain,
TOKEN_PARAM to token,
),
)
data class FeeSummaryScreenOpened(
val categoryName: String,
val source: CommonSendSource,
val blockchain: String,
val token: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Fee Summary Screen Opened",
params = mapOf(
SOURCE to source.analyticsName,
BLOCKCHAIN to blockchain,
TOKEN_PARAM to token,
),
)
data class FeeTokenScreenOpened(
val categoryName: String,
val source: CommonSendSource,
val availableTokens: String,
val blockchain: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Fee Token Screen Opened",
params = mapOf(
SOURCE to source.analyticsName,
"Available Fee" to availableTokens,
BLOCKCHAIN to blockchain,
),
)
@Suppress("NullableToStringCall")
/** Confirmation screen opened */
data class ConfirmationScreenOpened(
val categoryName: String,
val source: CommonSendSource,
val fromDerivationIndex: Int?,
val toDerivationIndex: Int?,
val sendBlockchain: String,
val sendToken: String,
val type: SendEntryType = SendEntryType.Manual,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Confirm Screen Opened",
params = buildMap {
put(SOURCE, source.analyticsName)
put("Token", sendToken)
put("Blockchain", sendBlockchain)
if (fromDerivationIndex != null || toDerivationIndex != null) {
put(
"Account Derivation From or To (optional)",
"$fromDerivationIndex, $toDerivationIndex",
)
}
put(TYPE, type.analyticsName)
},
), AppsFlyerIncludedEvent
/** If transaction delays notification is present */
data class NoticeTransactionDelays(
val categoryName: String,
val token: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Notice - Transaction Delays Are Possible",
params = mapOf(TOKEN_PARAM to token),
)
/** If error occurs during send transactions */
data class TransactionError(
val categoryName: String,
val token: String,
val blockchain: String,
val errorCode: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Error - Transaction Rejected",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
ERROR_CODE to errorCode,
),
)
/** Close button clicked */
data class CloseButtonClicked(
val categoryName: String,
val source: SendScreenSource,
val isFromSummary: Boolean,
val isValid: Boolean,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Button - Close",
params = mapOf(
SOURCE to source.name,
"FromSummary" to if (isFromSummary) "Yes" else "No",
"isValid" to if (isValid) "Yes" else "No",
),
)
/** Share button clicked */
data class ShareButtonClicked(
val categoryName: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Button - Share",
)
/** Expore button clicked */
data class ExploreButtonClicked(
val categoryName: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Button - Explore",
)
/** Screen reopened from confirmation screen */
data class ScreenReopened(
val categoryName: String,
val source: SendScreenSource,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Screen Reopened",
params = mapOf(SOURCE to source.name),
)
/** Fee screen is closed with non empty nonce */
data class NonceInserted(
val categoryName: String,
val token: String,
val blockchain: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Nonce Inserted",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
),
)
/** Token chosen to convert with sending */
data class TokenChosen(
val categoryName: String,
val token: String,
val blockchain: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Token chosen",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
),
)
companion object {
const val SEND_CATEGORY = "Token / Send"
const val SWAP_CATEGORY = "Swap"
const val NFT_SEND_CATEGORY = "NFT"
const val APPROVE_CATEGORY = "Approve"
}
enum class SendScreenSource {
Address,
Amount,
Fee,
Confirm,
}
enum class SendEntryType(val analyticsName: String) {
QR("QR"),
Manual("Manually"),
}
enum class CommonSendSource(val analyticsName: String) {
Send("Send"),
Swap("Swap"),
SendWithSwap("Send&Swap"),
WalletConnect("WalletConnect"),
NFT("NFT"),
Approve("Approve"),
}
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.send.api.callbacks
import com.tangem.features.send.api.entity.FeeSelectorUM
interface FeeSelectorModelCallback {
fun onFeeResult(feeSelectorUM: FeeSelectorUM)
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.send.api.deeplink
import kotlinx.coroutines.CoroutineScope
interface SellRedirectDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): SellRedirectDeepLinkHandler
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.api.entity
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
data class CustomFeeFieldUM(
val value: String,
val onValueChange: (String) -> Unit,
val keyboardOptions: KeyboardOptions,
val keyboardActions: KeyboardActions,
val symbol: String?,
val decimals: Int,
val title: TextReference,
val footer: TextReference,
val label: TextReference? = null,
val isReadonly: Boolean = false,
)

View file

@ -0,0 +1,130 @@
package com.tangem.features.send.api.entity
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.api.R
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
import java.math.BigInteger
@Immutable
sealed class FeeSelectorUM {
abstract val isPrimaryButtonEnabled: Boolean
data object Loading : FeeSelectorUM() {
override val isPrimaryButtonEnabled = false
}
data class Error(
val error: GetFeeError,
val isHidden: Boolean = false,
) : FeeSelectorUM() {
override val isPrimaryButtonEnabled = false
}
data class Content(
override val isPrimaryButtonEnabled: Boolean,
val fees: TransactionFee,
val feeItems: ImmutableList<FeeItem>,
val selectedFeeItem: FeeItem,
val feeExtraInfo: FeeExtraInfo,
val feeFiatRateUM: FeeFiatRateUM?,
val feeNonce: FeeNonce,
) : FeeSelectorUM() {
fun toAnalyticType(): AnalyticsParam.FeeType = when (fees) {
is TransactionFee.Single -> AnalyticsParam.FeeType.Fixed
is TransactionFee.Choosable -> when (selectedFeeItem) {
is FeeItem.Suggested,
is FeeItem.Custom,
-> AnalyticsParam.FeeType.Custom
is FeeItem.Fast -> AnalyticsParam.FeeType.Max
is FeeItem.Market, is FeeItem.Loading -> AnalyticsParam.FeeType.Normal
is FeeItem.Slow -> AnalyticsParam.FeeType.Min
}
}
}
}
@Immutable
data class FeeFiatRateUM(
val rate: BigDecimal,
val appCurrency: AppCurrency,
)
@Immutable
data class FeeExtraInfo(
val isFeeApproximate: Boolean,
val isFeeConvertibleToFiat: Boolean,
val isTronToken: Boolean,
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val availableFeeCurrencies: ImmutableList<CryptoCurrencyStatus>? = null,
val transactionFeeExtended: TransactionFeeExtended? = null,
val isNotEnoughFunds: Boolean = false,
)
sealed class FeeNonce {
data object None : FeeNonce()
data class Nonce(
val nonce: BigInteger?,
val onNonceChange: (String) -> Unit,
) : FeeNonce()
}
@Immutable
sealed class FeeItem {
abstract val fee: Fee
abstract val title: TextReference
abstract val iconRes: Int
fun isSameClass(other: FeeItem): Boolean {
return this::class == other::class
}
fun isLoading(): Boolean {
return this is Loading
}
object Loading : FeeItem() {
override val title: TextReference = resourceReference(R.string.common_fee_selector_option_market)
override val iconRes: Int = R.drawable.ic_bird_24
override val fee: Fee = Fee.Common(Amount(BigDecimal.ZERO, Blockchain.Unknown))
}
data class Suggested(
override val title: TextReference,
override val fee: Fee,
) : FeeItem() {
override val iconRes: Int = R.drawable.ic_star_mini_24
}
data class Slow(override val fee: Fee) : FeeItem() {
override val title: TextReference = resourceReference(R.string.common_fee_selector_option_slow)
override val iconRes: Int = R.drawable.ic_tortoise_24
}
data class Market(override val fee: Fee) : FeeItem() {
override val title: TextReference = resourceReference(R.string.common_fee_selector_option_market)
override val iconRes: Int = R.drawable.ic_bird_24
}
data class Fast(override val fee: Fee) : FeeItem() {
override val title: TextReference = resourceReference(R.string.common_fee_selector_option_fast)
override val iconRes: Int = R.drawable.ic_hare_24
}
data class Custom(override val fee: Fee, val customValues: ImmutableList<CustomFeeFieldUM>) : FeeItem() {
override val title: TextReference = resourceReference(R.string.common_custom)
override val iconRes: Int = R.drawable.ic_edit_v2_24
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.send.api.entity
sealed class PredefinedValues {
data object Empty : PredefinedValues()
sealed class Content : PredefinedValues() {
abstract val address: String
abstract val amount: String?
abstract val memo: String?
data class Deeplink(
override val amount: String,
override val address: String,
override val memo: String?,
val transactionId: String,
) : Content()
data class QrCode(
override val amount: String?,
override val address: String,
override val memo: String?,
val source: Source,
) : Content()
}
enum class Source {
MAIN_SCREEN,
SEND_SCREEN,
}
}
inline val PredefinedValues.isFromMainScreenQr: Boolean
get() = (this as? PredefinedValues.Content.QrCode)
?.source == PredefinedValues.Source.MAIN_SCREEN

View file

@ -0,0 +1,18 @@
package com.tangem.features.send.api.entry
import com.tangem.core.decompose.navigation.Route
/**
* Route for switching send and send via swap flows.
*/
sealed class SendEntryRoute : Route {
/** Route to send screen */
data object Send : SendEntryRoute()
/** Route to send via swap screen */
data object SendWithSwap : SendEntryRoute()
/** Route to choose token screen for send via swap */
data class ChooseToken(
val isShowSendViaSwapNotification: Boolean,
) : SendEntryRoute()
}

View file

@ -0,0 +1,76 @@
package com.tangem.features.send.api.params
import arrow.core.Either
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.entity.FeeSelectorUM
sealed class FeeSelectorParams {
abstract val state: FeeSelectorUM
abstract val userWalletId: UserWalletId
abstract val onLoadFeeExtended: (suspend (CryptoCurrencyStatus?) -> Either<GetFeeError, TransactionFeeExtended>)?
abstract val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>
abstract val onDisableCustomFee: () -> Boolean
abstract val cryptoCurrencyStatus: CryptoCurrencyStatus
abstract val feeCryptoCurrencyStatus: CryptoCurrencyStatus
abstract val feeStateConfiguration: FeeStateConfiguration
abstract val feeDisplaySource: FeeDisplaySource
abstract val analyticsCategoryName: String
abstract val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource
abstract val shouldShowOnlySpeedOption: Boolean
data class FeeSelectorBlockParams(
override val state: FeeSelectorUM,
override val userWalletId: UserWalletId,
override val onLoadFeeExtended: (
suspend (CryptoCurrencyStatus?) -> Either<GetFeeError, TransactionFeeExtended>
)? = null,
override val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
override val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
override val feeStateConfiguration: FeeStateConfiguration,
override val feeDisplaySource: FeeDisplaySource,
override val analyticsCategoryName: String,
override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
override val shouldShowOnlySpeedOption: Boolean = false,
override val onDisableCustomFee: () -> Boolean = { false },
val bottomSheetShown: (Boolean) -> Unit = {},
) : FeeSelectorParams()
data class FeeSelectorDetailsParams(
override val state: FeeSelectorUM,
override val userWalletId: UserWalletId,
override val onLoadFeeExtended: (
suspend (CryptoCurrencyStatus?) -> Either<GetFeeError, TransactionFeeExtended>
)? = null,
override val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
override val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
override val feeStateConfiguration: FeeStateConfiguration,
override val feeDisplaySource: FeeDisplaySource,
override val analyticsCategoryName: String,
override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
override val shouldShowOnlySpeedOption: Boolean = false,
override val onDisableCustomFee: () -> Boolean = { false },
val callback: FeeSelectorModelCallback,
) : FeeSelectorParams()
sealed class FeeStateConfiguration {
data object None : FeeStateConfiguration()
data class Suggestion(val title: TextReference, val fee: Fee) : FeeStateConfiguration()
data object ExcludeLow : FeeStateConfiguration()
}
enum class FeeDisplaySource {
Screen,
BottomSheet,
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.send.api.subcomponents.amount.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
sealed class CommonSendAmountAnalyticEvents(
category: String,
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = category, event = event, params = params) {
/** Selected currency */
data class SelectedCurrency(
val categoryName: String,
val type: SelectedCurrencyType,
) : CommonSendAmountAnalyticEvents(
category = categoryName,
event = "Selected Currency",
params = mapOf(TYPE to type.value),
)
/** Max amount button clicked */
data class MaxAmountButtonClicked(
val categoryName: String,
val token: String,
val blockchain: String,
val source: CommonSendAnalyticEvents.CommonSendSource,
) : CommonSendAmountAnalyticEvents(
category = categoryName,
event = "Max Amount Taped",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
SOURCE to source.analyticsName,
),
)
enum class SelectedCurrencyType(val value: String) {
Token("Token"),
AppCurrency("App Currency"),
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.send.api.subcomponents.destination
/**
* Common route for destination
*/
interface DestinationRoute {
val isEditMode: Boolean
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.send.api.subcomponents.destination
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
interface SendDestinationBlockComponent : ComposableContentComponent {
fun updateState(destinationUM: DestinationUM)
interface Factory {
fun create(
context: AppComponentContext,
params: SendDestinationComponentParams.DestinationBlockParams,
onClick: () -> Unit,
onResult: (DestinationUM) -> Unit,
): SendDestinationBlockComponent
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.send.api.subcomponents.destination
import com.tangem.common.ui.navigationButtons.NavigationModelCallback
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
interface SendDestinationComponent : ComposableContentComponent {
fun updateState(destinationUM: DestinationUM)
interface ModelCallback : NavigationModelCallback {
fun onDestinationResult(destinationUM: DestinationUM)
}
interface Factory : ComponentFactory<SendDestinationComponentParams.DestinationParams, SendDestinationComponent>
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.api.subcomponents.destination
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
sealed class SendDestinationComponentParams {
abstract val state: DestinationUM
abstract val analyticsCategoryName: String
abstract val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource
abstract val userWalletId: UserWalletId
abstract val cryptoCurrency: CryptoCurrency
abstract val isAllowSelfSend: Boolean
data class DestinationParams(
override val state: DestinationUM,
override val analyticsCategoryName: String,
override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
override val cryptoCurrency: CryptoCurrency,
override val userWalletId: UserWalletId,
val title: TextReference,
val isBalanceHidingFlow: StateFlow<Boolean>,
val currentRoute: Flow<DestinationRoute>,
val callback: SendDestinationComponent.ModelCallback,
override val isAllowSelfSend: Boolean = false,
) : SendDestinationComponentParams()
data class DestinationBlockParams(
override val state: DestinationUM,
override val analyticsCategoryName: String,
override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
override val userWalletId: UserWalletId,
override val cryptoCurrency: CryptoCurrency,
val blockClickEnableFlow: StateFlow<Boolean>,
val predefinedValues: PredefinedValues,
override val isAllowSelfSend: Boolean = false,
) : SendDestinationComponentParams()
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.send.api.subcomponents.destination.entity
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
@Immutable
data class DestinationRecipientListUM(
val id: String,
val title: TextReference = TextReference.EMPTY,
val subtitle: TextReference = TextReference.EMPTY,
val accountTitleUM: AccountTitleUM.Account? = null,
val timestamp: TextReference? = null,
val subtitleEndOffset: Int = 0,
@DrawableRes val subtitleIconRes: Int? = null,
val isVisible: Boolean = true,
val isLoading: Boolean = false,
val userWalletId: UserWalletId? = null,
val network: Network? = null,
val accountId: AccountId? = null,
val address: String? = null,
)

View file

@ -0,0 +1,54 @@
package com.tangem.features.send.api.subcomponents.destination.entity
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.utils.toBriefAddressFormat
@Immutable
sealed class DestinationTextFieldUM {
/** Current value */
abstract val value: String
/** Keyboard options */
abstract val keyboardOptions: KeyboardOptions
data class RecipientAddress(
override val value: String,
override val keyboardOptions: KeyboardOptions,
val placeholder: TextReference,
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val isValuePasted: Boolean,
// if value is human-readable address, this field contains the actual blockchain address
val blockchainAddress: String? = null,
) : DestinationTextFieldUM() {
val actualAddress: String
get() = blockchainAddress ?: value
// if value is human-readable address, this field contains the actual brief blockchain address
val briefBlockchainAddress: String?
get() = blockchainAddress?.toBriefAddressFormat(BRIEF_ADDRESS_EDGE_LENGTH, BRIEF_ADDRESS_EDGE_LENGTH)
val isAddressEns = blockchainAddress != null
}
data class RecipientMemo(
override val value: String,
override val keyboardOptions: KeyboardOptions,
val placeholder: TextReference,
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val disabledText: TextReference,
val isEnabled: Boolean,
val isValuePasted: Boolean,
) : DestinationTextFieldUM()
private companion object {
const val BRIEF_ADDRESS_EDGE_LENGTH = 13
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.send.api.subcomponents.destination.entity
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
sealed class DestinationUM {
abstract val isPrimaryButtonEnabled: Boolean
data class Content(
override val isPrimaryButtonEnabled: Boolean,
val addressTextField: DestinationTextFieldUM.RecipientAddress,
val memoTextField: DestinationTextFieldUM.RecipientMemo?,
val recent: ImmutableList<DestinationRecipientListUM>,
val wallets: ImmutableList<DestinationRecipientListUM>,
val networkName: String,
val isValidating: Boolean = false,
val isInitialized: Boolean = false,
val isRecentHidden: Boolean,
val isAccountsMode: Boolean? = null,
) : DestinationUM()
data class Empty(
override val isPrimaryButtonEnabled: Boolean = false,
) : DestinationUM()
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.send.api.subcomponents.feeSelector
import kotlinx.coroutines.flow.Flow
interface FeeSelectorCheckReloadTrigger {
/** Trigger return callback with check result */
suspend fun callbackCheckResult(isSuccess: Boolean)
/** Trigger fee check reload */
suspend fun triggerCheckUpdate()
}
interface FeeSelectorCheckReloadListener {
/**
* Flow triggers fee check reload before transaction.
* Usually after significant time after fee was updated last time
*/
val checkReloadTriggerFlow: Flow<Unit>
/** Flow return result of fee check update */
val checkReloadResultFlow: Flow<Boolean>
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.send.api.subcomponents.feeSelector
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorData
import kotlinx.coroutines.flow.Flow
interface FeeSelectorReloadTrigger {
/** Trigger fee update */
suspend fun triggerUpdate(feeData: FeeSelectorData = FeeSelectorData())
/** Triggers fee loading status. Used in cases where fee loading state is extended due to business logic */
suspend fun triggerLoadingState()
}
interface FeeSelectorReloadListener {
/** Flow triggers fee reload */
val reloadTriggerFlow: Flow<FeeSelectorData>
/** Triggers fee loading status. Used in cases where fee loading state is extended due to business logic */
val loadingStateTriggerFlow: Flow<Unit>
}

View file

@ -0,0 +1,59 @@
package com.tangem.features.send.api.subcomponents.feeSelector.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
sealed class CommonSendFeeAnalyticEvents(
category: String,
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = category, event = event, params = params) {
abstract val categoryName: String
/** Selected fee (send after next screen opened) */
data class SelectedFee(
override val categoryName: String,
val feeType: AnalyticsParam.FeeType,
val source: CommonSendAnalyticEvents.CommonSendSource,
val feeToken: String,
val blockchain: String,
) : CommonSendFeeAnalyticEvents(
category = categoryName,
event = "Fee Selected",
params = mapOf(
FEE_TYPE to feeType.value,
SOURCE to source.analyticsName,
FEE_TOKEN to feeToken,
BLOCKCHAIN to blockchain,
),
)
/** Custom fee selected */
data class CustomFeeButtonClicked(
override val categoryName: String,
val blockchain: String,
val token: String,
) : CommonSendFeeAnalyticEvents(
category = categoryName,
event = "Custom Fee Clicked",
params = mapOf(
BLOCKCHAIN to blockchain,
TOKEN_PARAM to token,
),
)
/** Custom fee edited */
data class GasPriceInserter(
override val categoryName: String,
) : CommonSendFeeAnalyticEvents(
category = categoryName,
event = "Gas Price Inserted",
)
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.send.api.subcomponents.feeSelector.entity
data class FeeSelectorData(
val isRemoveSuggestedFee: Boolean = false,
)

View file

@ -0,0 +1,100 @@
package com.tangem.features.send.api.subcomponents.feeSelector.utils
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
import java.math.RoundingMode
object FeeCalculationUtils {
private val FEE_MAX_DIFF = BigDecimal("5")
/**
* Check and calculates subtracted amount
*/
fun checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus,
amountValue: BigDecimal,
feeValue: BigDecimal?,
reduceAmountBy: BigDecimal,
): BigDecimal {
if (feeValue == null || feeValue.isZero()) return amountValue
val balance = cryptoCurrencyStatus.value.amount ?: return amountValue
val isFeeCoverage = checkFeeCoverage(
isSubtractAvailable = isAmountSubtractAvailable,
balance = balance,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
return if (isFeeCoverage) {
balance.minus(reduceAmountBy).minus(feeValue)
} else {
amountValue
}
}
/**
* Check if custom fee is too high
*/
fun checkIfCustomFeeTooHigh(feeSelectorUM: FeeSelectorUM.Content): Pair<Boolean, String> {
val defaultResult = false to ""
if (feeSelectorUM.selectedFeeItem !is FeeItem.Custom) return defaultResult
val customAmount = feeSelectorUM.selectedFeeItem.customValues.firstOrNull() ?: return defaultResult
val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return defaultResult
val highValue = multipleFees.priority.amount.value ?: return defaultResult
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = if (highValue > BigDecimal.ZERO) {
customValue / highValue
} else {
BigDecimal.ZERO
}
val isFeeTooHigh = diff > FEE_MAX_DIFF
return isFeeTooHigh to diff.parseBigDecimal(0, RoundingMode.HALF_UP)
}
/**
* Check if custom fee is too low
*/
fun checkIfCustomFeeTooLow(feeSelectorUM: FeeSelectorUM.Content): Boolean {
if (feeSelectorUM.selectedFeeItem !is FeeItem.Custom) return false
val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return false
val minimumValue = multipleFees.minimum.amount.value ?: return false
val customAmount = feeSelectorUM.selectedFeeItem.customValues.firstOrNull() ?: return false
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
return minimumValue > customValue
}
/**
* Checks if sending amount with fee is greater than balance
*/
fun checkFeeCoverage(
isSubtractAvailable: Boolean,
balance: BigDecimal,
amountValue: BigDecimal,
feeValue: BigDecimal,
reduceAmountBy: BigDecimal?,
): Boolean {
if (!isSubtractAvailable) return false
val reducedBy = balance - (reduceAmountBy ?: BigDecimal.ZERO)
return reducedBy < amountValue + feeValue && reducedBy > feeValue && reducedBy >= amountValue
}
/**
* Checks if fee exceeds fee paid currency balance
*/
fun checkExceedBalance(feeBalance: BigDecimal?, feeAmount: BigDecimal?): Boolean {
return feeAmount == null || feeBalance == null || feeAmount.isZero() || feeAmount > feeBalance
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.api.subcomponents.notifications
import com.tangem.features.send.api.SendNotificationsComponent
import kotlinx.coroutines.flow.Flow
interface SendNotificationsUpdateListener {
/** Flow triggers notifications update */
val updateTriggerFlow: Flow<SendNotificationsComponent.Params.NotificationData>
/** Flow returns whether there is error notifications */
val hasErrorFlow: Flow<Boolean>
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.send.api.subcomponents.notifications
import com.tangem.features.send.api.SendNotificationsComponent
interface SendNotificationsUpdateTrigger {
/** Trigger return callback with check result */
suspend fun callbackHasError(hasError: Boolean)
/** Trigger fee check reload */
suspend fun triggerUpdate(data: SendNotificationsComponent.Params.NotificationData)
}

View file

@ -0,0 +1,66 @@
package com.tangem.features.send.api.utils
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.api.R
import com.tangem.utils.StringsSigns.COMA_SIGN
fun getTronTokenFeeSendingText(fee: Fee.Tron, fiatFee: String, fiatSending: TextReference): TextReference {
val suffix = when {
fee.remainingEnergy == 0L -> {
resourceReference(
R.string.send_summary_transaction_description_suffix_including,
wrappedList(fiatFee),
)
}
fee.feeEnergy <= fee.remainingEnergy -> {
resourceReference(
R.string.send_summary_transaction_description_suffix_fee_covered,
wrappedList(fee.feeEnergy),
)
}
else -> {
resourceReference(
R.string.send_summary_transaction_description_suffix_fee_reduced,
wrappedList(fee.remainingEnergy),
)
}
}
val prefix = resourceReference(
R.string.send_summary_transaction_description_prefix,
wrappedList(fiatSending),
)
return combinedReference(prefix, stringReference("$COMA_SIGN "), suffix)
}
fun formatFooterFiatFee(
amount: Amount?,
isFeeConvertibleToFiat: Boolean,
isFeeApproximate: Boolean,
appCurrency: AppCurrency,
): String {
return if (isFeeConvertibleToFiat) {
amount?.value.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
} else {
amount?.value.format {
crypto(
decimals = amount?.decimals ?: 0,
symbol = amount?.currencySymbol.orEmpty(),
).fee(
canBeLower = isFeeApproximate,
)
}
}
}

View file

@ -0,0 +1,190 @@
package com.tangem.features.send.api.subcomponents.feeSelector.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class FeeCalculationUtilsTest {
@Test
fun `GIVEN amount subtract available and fee coverage needed WHEN checkAndCalculateSubtractedAmount THEN returns subtracted amount`() {
// GIVEN
val isAmountSubtractAvailable = true
val cryptoCurrencyStatus = createCryptoCurrencyStatus(BigDecimal("6"))
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(BigDecimal("3"))
}
@Test
fun `GIVEN amount subtract not available WHEN checkAndCalculateSubtractedAmount THEN returns original amount`() {
// GIVEN
val isAmountSubtractAvailable = false
val cryptoCurrencyStatus = createCryptoCurrencyStatus(BigDecimal("10"))
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(amountValue)
}
@Test
fun `GIVEN sufficient balance for amount and fee WHEN checkAndCalculateSubtractedAmount THEN returns original amount`() {
// GIVEN
val isAmountSubtractAvailable = true
val cryptoCurrencyStatus = createCryptoCurrencyStatus(BigDecimal("10"))
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(amountValue)
}
@Test
fun `GIVEN no balance WHEN checkAndCalculateSubtractedAmount THEN returns original amount`() {
// GIVEN
val isAmountSubtractAvailable = true
val cryptoCurrencyStatus = createCryptoCurrencyStatus(null)
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(amountValue)
}
@Test
fun `GIVEN fee exceeds balance WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance = BigDecimal("5")
val feeAmount = BigDecimal("10")
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
@Test
fun `GIVEN fee within balance WHEN checkExceedBalance THEN returns false`() {
// GIVEN
val feeBalance = BigDecimal("10")
val feeAmount = BigDecimal("5")
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isFalse()
}
@Test
fun `GIVEN null fee amount WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance = BigDecimal("10")
val feeAmount: BigDecimal? = null
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
@Test
fun `GIVEN null fee balance WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance: BigDecimal? = null
val feeAmount = BigDecimal("5")
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
@Test
fun `GIVEN zero fee amount WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance = BigDecimal("10")
val feeAmount = BigDecimal.ZERO
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
private fun createCryptoCurrencyStatus(amount: BigDecimal?): CryptoCurrencyStatus {
val value = if (amount != null) {
CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = BigDecimal.ZERO,
fiatRate = BigDecimal.ZERO,
priceChange = BigDecimal.ZERO,
stakingBalance = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
yieldSupplyStatus = null,
sources = CryptoCurrencyStatus.Sources(),
)
} else {
CryptoCurrencyStatus.NoAmount(
priceChange = BigDecimal.ZERO,
fiatRate = BigDecimal.ZERO,
)
}
return CryptoCurrencyStatus(
currency = mockk(relaxed = true),
value = value,
)
}
}