Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-27 11:08:57 +05:00
parent 80d00dfb6b
commit eee3d40e31
41 changed files with 2811 additions and 5 deletions

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.send.v2.send.model.SendModel
import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel
import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -29,4 +30,9 @@ internal interface SendModelModule {
@IntoMap
@ClassKey(SendDestinationModel::class)
fun provideSendDestinationModel(model: SendDestinationModel): Model
@Binds
@IntoMap
@ClassKey(SendFeeModel::class)
fun provideSendFeeModel(model: SendFeeModel): Model
}

View file

@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
@ -17,6 +18,9 @@ import com.tangem.features.send.v2.send.model.SendModel
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -66,7 +70,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
SendRoute.Empty -> getStubComponent()
is SendRoute.Destination -> getDestinationComponent(factoryContext, route)
is SendRoute.Amount -> getAmountComponent(factoryContext, route)
is SendRoute.Fee -> getFeeComponent()
is SendRoute.Fee -> getFeeComponent(factoryContext)
SendRoute.Confirm -> getConfirmComponent()
}
@ -99,7 +103,30 @@ internal class DefaultSendComponent @AssistedInject constructor(
),
)
private fun getFeeComponent() = getStubComponent() // todo
private fun getFeeComponent(factoryContext: AppComponentContext): ComposableContentComponent {
val state = model.uiState.value
val sendAmount = (state.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value
return if (sendAmount != null && destinationAddress != null) {
SendFeeComponent(
appComponentContext = factoryContext,
params = SendFeeComponentParams.FeeParams(
state = model.uiState.value.feeUM,
currentRoute = currentRoute.filterIsInstance<SendRoute.Fee>(),
analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY,
userWallet = model.userWallet,
cryptoCurrencyStatus = model.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus,
appCurrency = model.appCurrency,
sendAmount = sendAmount,
destinationAddress = destinationAddress,
callback = model,
),
)
} else {
getStubComponent()
}
}
private fun getConfirmComponent() = getStubComponent() // todo

View file

@ -11,6 +11,8 @@ import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -25,13 +27,15 @@ internal class SendModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
) : Model(),
SendDestinationComponent.ModelCallback,
SendAmountComponent.ModelCallback {
SendAmountComponent.ModelCallback,
SendFeeComponent.ModelCallback {
private val _uiState = MutableStateFlow(initialState())
val uiState = _uiState.asStateFlow()
var userWallet: UserWallet by Delegates.notNull()
var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
var feeCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
var appCurrency: AppCurrency = AppCurrency.Default
var predefinedAmountValue: String? = null
@ -39,12 +43,17 @@ internal class SendModel @Inject constructor(
_uiState.update { it.copy(destinationUM = destinationUM) }
}
override fun onAmountResult(state: AmountState) {
_uiState.update { it.copy(amountUM = state) }
override fun onAmountResult(amountUM: AmountState) {
_uiState.update { it.copy(amountUM = amountUM) }
}
override fun onFeeResult(feeUM: FeeUM) {
_uiState.update { it.copy(feeUM = feeUM) }
}
private fun initialState(): SendUM = SendUM(
amountUM = AmountState.Empty(),
destinationUM = DestinationUM.Empty(),
feeUM = FeeUM.Empty(),
)
}

View file

@ -2,8 +2,10 @@ package com.tangem.features.send.v2.send.ui.state
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
internal data class SendUM(
val amountUM: AmountState,
val destinationUM: DestinationUM,
val feeUM: FeeUM,
)

View file

@ -0,0 +1,48 @@
package com.tangem.features.send.v2.subcomponents
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.ImmutableList
internal fun LazyListScope.notifications(
notifications: ImmutableList<NotificationUM>,
modifier: Modifier = Modifier,
hasPaddingAbove: Boolean = false,
isClickDisabled: Boolean = false,
) {
itemsIndexed(
items = notifications,
key = { _, item -> item::class.java },
contentType = { _, item -> item::class.java },
itemContent = { i, item ->
val topPadding = if (i == 0 && hasPaddingAbove) 0.dp else 12.dp
Notification(
config = item.config,
modifier = modifier
.padding(top = topPadding)
.animateItem(),
containerColor = when (item) {
is NotificationUM.Error.TokenExceedsBalance,
is NotificationUM.Warning.NetworkFeeUnreachable,
is NotificationUM.Warning.HighFeeError,
-> TangemTheme.colors.background.action
else -> TangemTheme.colors.button.disabled
},
iconTint = when (item) {
is NotificationUM.Error.TokenExceedsBalance,
is NotificationUM.Warning,
-> null
is NotificationUM.Error -> TangemTheme.colors.icon.warning
is NotificationUM.Info -> TangemTheme.colors.icon.accent
},
isEnabled = !isClickDisabled,
)
},
)
}

View file

@ -21,6 +21,8 @@ internal class SendDestinationComponent(
private val model: SendDestinationModel = getOrCreateModel(params = params, router = router)
fun updateState(state: DestinationUM) = model.updateState(state)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()

View file

@ -0,0 +1,42 @@
package com.tangem.features.send.v2.subcomponents.fee
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.subcomponents.fee.ui.FeeBlock
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
internal class SendFeeBlockComponent(
appComponentContext: AppComponentContext,
private val params: SendFeeComponentParams.FeeBlockParams,
val onResult: (FeeUM) -> Unit,
val onClick: () -> Unit,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendFeeModel = getOrCreateModel(params = params)
init {
model.uiState.onEach {
onResult(it)
}.launchIn(componentScope)
}
fun updateState(state: FeeUM) = model.updateState(state)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val isClickEnabled = params.blockClickEnableFlow.collectAsStateWithLifecycle()
FeeBlock(
feeUM = state.value,
isClickEnabled = isClickEnabled.value,
onClick = onClick,
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.send.v2.subcomponents.fee
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeModel
import com.tangem.features.send.v2.subcomponents.fee.ui.SendFeeContent
internal class SendFeeComponent(
appComponentContext: AppComponentContext,
private val params: SendFeeComponentParams.FeeParams,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendFeeModel = getOrCreateModel(params = params)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
SendFeeContent(state = state.value, clickIntents = model)
}
interface ModelCallback {
fun onFeeResult(state: FeeUM)
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.send.v2.subcomponents.fee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.send.SendRoute
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.math.BigDecimal
internal sealed class SendFeeComponentParams {
abstract val state: FeeUM
abstract val analyticsCategoryName: String
abstract val userWallet: UserWallet
abstract val cryptoCurrencyStatus: CryptoCurrencyStatus
abstract val feeCryptoCurrencyStatus: CryptoCurrencyStatus
abstract val appCurrency: AppCurrency
abstract val sendAmount: BigDecimal
abstract val destinationAddress: String
data class FeeParams(
override val state: FeeUM,
override val analyticsCategoryName: String,
override val userWallet: UserWallet,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
override val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
override val appCurrency: AppCurrency,
override val sendAmount: BigDecimal,
override val destinationAddress: String,
val currentRoute: Flow<SendRoute.Fee>,
val callback: SendFeeComponent.ModelCallback,
) : SendFeeComponentParams()
data class FeeBlockParams(
override val state: FeeUM,
override val analyticsCategoryName: String,
override val userWallet: UserWallet,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
override val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
override val appCurrency: AppCurrency,
override val sendAmount: BigDecimal,
override val destinationAddress: String,
val blockClickEnableFlow: StateFlow<Boolean>,
) : SendFeeComponentParams()
}

View file

@ -0,0 +1,59 @@
package com.tangem.features.send.v2.subcomponents.fee
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
interface SendFeeReloadTrigger {
/** Flow triggers fee reload */
val reloadTriggerFlow: Flow<Unit>
/** Trigger fee update */
suspend fun triggerUpdate()
}
interface SendFeeCheckReloadTrigger {
/**
* 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>
/** Trigger return callback with check result */
suspend fun callbackCheckResult(isSuccess: Boolean)
/** Trigger fee check reload */
suspend fun triggerCheckUpdate()
}
@Singleton
internal class DefaultSendFeeReloadTrigger @Inject constructor() : SendFeeReloadTrigger, SendFeeCheckReloadTrigger {
private val _reloadTriggerFlow = MutableSharedFlow<Unit>()
override val reloadTriggerFlow = _reloadTriggerFlow.asSharedFlow()
private val _checkReloadTriggerFlow = MutableSharedFlow<Unit>()
override val checkReloadTriggerFlow = _checkReloadTriggerFlow.asSharedFlow()
private val _checkReloadResultFlow = MutableSharedFlow<Boolean>()
override val checkReloadResultFlow = _checkReloadResultFlow.asSharedFlow()
override suspend fun triggerUpdate() {
_reloadTriggerFlow.emit(Unit)
}
override suspend fun triggerCheckUpdate() {
_checkReloadTriggerFlow.emit(Unit)
}
override suspend fun callbackCheckResult(isSuccess: Boolean) {
_checkReloadResultFlow.emit(isSuccess)
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.send.v2.subcomponents.fee.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
internal sealed class SendFeeAnalyticEvents(
category: String,
event: String,
params: Map<String, String> = mapOf(),
) : 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,
) : SendFeeAnalyticEvents(
category = categoryName,
event = "Fee Selected",
params = mapOf("Fee Type" to feeType.value),
)
/** Custom fee selected */
data class CustomFeeButtonClicked(
override val categoryName: String,
) : SendFeeAnalyticEvents(
category = categoryName,
event = "Custom Fee Clicked",
)
/** Custom fee edited */
data class GasPriceInserter(
override val categoryName: String,
) : SendFeeAnalyticEvents(
category = categoryName,
event = "Gas Price Inserted",
)
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.send.v2.subcomponents.fee.di
import com.tangem.features.send.v2.subcomponents.fee.DefaultSendFeeReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.SendFeeReloadTrigger
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
internal object SendFeeModule {
@Provides
@Singleton
fun provideSendFeeReloadTrigger(): SendFeeReloadTrigger {
return DefaultSendFeeReloadTrigger()
}
@Provides
@Singleton
fun provideSendFeeCheckReloadTrigger(): SendFeeCheckReloadTrigger {
return DefaultSendFeeReloadTrigger()
}
}

View file

@ -0,0 +1,90 @@
package com.tangem.features.send.v2.subcomponents.fee.model
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.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
import java.math.RoundingMode
private val FEE_MAX_DIFF = BigDecimal("5")
/**
* Check and calculates subtracted amount
*/
internal fun checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus,
amountValue: BigDecimal,
feeValue: BigDecimal,
reduceAmountBy: BigDecimal,
): BigDecimal {
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
*/
internal fun checkIfFeeTooHigh(feeSelectorState: FeeSelectorUM.Content): Pair<Boolean, String> {
val defaultResult = false to ""
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return defaultResult
val highValue = multipleFees.priority.amount.value ?: return defaultResult
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return defaultResult
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = if (highValue > BigDecimal.ZERO) {
customValue / highValue
} else {
BigDecimal.ZERO
}
val isFeeTooHigh = feeSelectorState.selectedType == FeeType.Custom && diff > FEE_MAX_DIFF
return isFeeTooHigh to diff.parseBigDecimal(0, RoundingMode.HALF_UP)
}
/**
* Check if custom fee is too low
*/
internal fun checkIfFeeTooLow(feeSelectorUM: FeeSelectorUM.Content): Boolean {
val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return false
val minimumValue = multipleFees.minimum.amount.value ?: return false
val customAmount = feeSelectorUM.customValues.firstOrNull() ?: return false
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
return feeSelectorUM.selectedType == FeeType.Custom && minimumValue > customValue
}
/**
* Checks if sending amount with fee is greater than balance
*/
internal 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,127 @@
package com.tangem.features.send.v2.subcomponents.fee.model
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import java.math.BigDecimal
import javax.inject.Inject
@ModelScoped
internal class SendFeeAlertFactory @Inject constructor(
private val messageSender: UiMessageSender,
) {
fun checkAndShowAlerts(feeSelectorUM: FeeSelectorUM.Content, onConfirmClick: () -> Unit) {
val showFeeTooLow = checkAndShowFeeTooLow(feeSelectorUM, onConfirmClick)
val showFeeTooHigh = checkAndShowFeeTooHigh(feeSelectorUM, onConfirmClick)
if (!showFeeTooLow && !showFeeTooHigh) {
onConfirmClick()
}
}
/**
* Check if custom fee is too low
*/
private fun checkAndShowFeeTooLow(feeSelectorUM: FeeSelectorUM.Content, onConfirmClick: () -> Unit): Boolean {
val isFeeTooLow = checkIfFeeTooLow(feeSelectorUM)
if (isFeeTooLow) {
messageSender.send(
DialogMessage(
message = resourceReference(id = R.string.send_alert_fee_too_low_text),
dismissOnFirstAction = true,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_continue),
onClick = onConfirmClick,
)
},
secondActionBuilder = { cancelAction() },
),
)
}
return isFeeTooLow
}
/**
* Check if custom fee is too high
*/
private fun checkAndShowFeeTooHigh(feeSelectorUM: FeeSelectorUM.Content, onConfirmClick: () -> Unit): Boolean {
val (isFeeTooHigh, diff) = checkIfFeeTooHigh(feeSelectorUM)
if (isFeeTooHigh) {
messageSender.send(
DialogMessage(
message = resourceReference(
id = R.string.send_alert_fee_too_high_text,
formatArgs = wrappedList(diff),
),
dismissOnFirstAction = true,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_continue),
onClick = onConfirmClick,
)
},
secondActionBuilder = { cancelAction() },
),
)
}
return isFeeTooHigh
}
fun getFeeUpdatedAlert(newFee: TransactionFee, feeUM: FeeUM, onFeeNotIncreased: () -> Unit) {
if (feeUM !is FeeUM.Content) return
val feeSelectorUM = feeUM.feeSelectorUM as? FeeSelectorUM.Content ?: return
val newFee = when (newFee) {
is TransactionFee.Single -> newFee.normal
is TransactionFee.Choosable -> {
when (feeSelectorUM.selectedType) {
FeeType.Slow -> newFee.minimum
FeeType.Market -> newFee.normal
FeeType.Fast -> newFee.priority
FeeType.Custom -> return
}
}
}
val newFeeValue = newFee.amount.value ?: BigDecimal.ZERO
val oldFeeValue = feeSelectorUM.selectedFee?.amount?.value ?: BigDecimal.ZERO
if (newFeeValue > oldFeeValue) {
messageSender.send(
DialogMessage(
message = resourceReference(id = R.string.send_notification_high_fee_title),
dismissOnFirstAction = true,
firstActionBuilder = { okAction() },
secondActionBuilder = { cancelAction() },
),
)
} else {
onFeeNotIncreased()
}
}
fun getFeeUnreachableErrorState(onFeeReload: () -> Unit) {
messageSender.send(
DialogMessage(
title = resourceReference(R.string.send_fee_unreachable_error_title),
message = resourceReference(R.string.send_fee_unreachable_error_text),
dismissOnFirstAction = true,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.warning_button_refresh),
onClick = onFeeReload,
)
},
),
)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.send.v2.subcomponents.fee.model
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
internal interface SendFeeClickIntents {
fun feeReload()
fun onFeeSelectorClick(feeType: FeeType)
fun onCustomFeeValueChange(index: Int, value: String)
fun onReadMoreClick()
fun onNextClick()
}

View file

@ -0,0 +1,290 @@
package com.tangem.features.send.v2.subcomponents.fee.model
import androidx.compose.runtime.Stable
import com.tangem.blockchain.common.AmountType
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams
import com.tangem.features.send.v2.subcomponents.fee.SendFeeReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.analytics.SendFeeAnalyticEvents
import com.tangem.features.send.v2.subcomponents.fee.analytics.SendFeeAnalyticEvents.GasPriceInserter
import com.tangem.features.send.v2.subcomponents.fee.model.transformers.*
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class SendFeeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
private val sendFeeAlertFactory: SendFeeAlertFactory,
private val feeReloadTrigger: SendFeeReloadTrigger,
private val feeCheckReloadTrigger: SendFeeCheckReloadTrigger,
) : Model(), SendFeeClickIntents {
private val params: SendFeeComponentParams = paramsContainer.require()
private val _uiState = MutableStateFlow(params.state)
val uiState = _uiState.asStateFlow()
private val analyticsCategoryName = params.analyticsCategoryName
private val appCurrency = params.appCurrency
private val cryptoCurrencyStatus = params.cryptoCurrencyStatus
private val feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus
private var feeJobHolder = JobHolder()
init {
configFeeNavigation()
subscribeOnFeeReloadTriggerUpdates()
subscribeOnFeeCheckReloadTriggerUpdates()
initialState()
loadFee()
}
fun updateState(state: FeeUM) {
_uiState.value = state
}
override fun feeReload() = loadFee()
override fun onFeeSelectorClick(feeType: FeeType) {
_uiState.update(
SendFeeSelectTransformer(
feeType = feeType,
clickIntents = this@SendFeeModel,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
),
)
updateFeeNotifications()
if (feeType == FeeType.Custom) {
analyticsEventHandler.send(
SendFeeAnalyticEvents.CustomFeeButtonClicked(categoryName = analyticsCategoryName),
)
}
}
override fun onCustomFeeValueChange(index: Int, value: String) {
_uiState.update(
SendFeeCustomValueChangeTransformer(
index = index,
value = value,
clickIntents = this@SendFeeModel,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
),
)
updateFeeNotifications()
}
override fun onReadMoreClick() {
val locale = if (Locale.getDefault().language == RU_LOCALE) RU_LOCALE else EN_LOCALE
val url = buildString {
append(FEE_READ_MORE_URL_FIRST_PART)
append(locale)
append(FEE_READ_MORE_URL_SECOND_PART)
}
urlOpener.openUrl(url)
}
override fun onNextClick() {
val feeUM = uiState.value as? FeeUM.Content
val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
if (feeSelectorUM != null) {
sendFeeAlertFactory.checkAndShowAlerts(feeSelectorUM) {
val isCustomFeeEdited =
feeSelectorUM.selectedFee?.amount?.value != feeSelectorUM.fees.normal.amount.value
if (feeSelectorUM.selectedType == FeeType.Custom && isCustomFeeEdited) {
analyticsEventHandler.send(GasPriceInserter(categoryName = analyticsCategoryName))
}
analyticsEventHandler.send(
SendFeeAnalyticEvents.SelectedFee(
categoryName = analyticsCategoryName,
feeType = feeSelectorUM.selectedType.toAnalyticType(feeSelectorUM),
),
)
navigate()
}
} else {
navigate()
}
}
private fun initialState() {
if (uiState.value is FeeUM.Empty) {
_uiState.update(
SendFeeInitialStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
appCurrency = appCurrency,
),
)
}
}
private fun subscribeOnFeeReloadTriggerUpdates() {
feeReloadTrigger.reloadTriggerFlow
.onEach { feeReload() }
.launchIn(modelScope)
}
private fun subscribeOnFeeCheckReloadTriggerUpdates() {
feeCheckReloadTrigger.checkReloadTriggerFlow
.onEach { checkLoadFee() }
.launchIn(modelScope)
}
private fun saveResult() {
_uiState.update(
SendFeeCustomAutoFixTransformer(
clickIntents = this@SendFeeModel,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
),
)
val params = params as? SendFeeComponentParams.FeeParams ?: return
params.callback.onFeeResult(uiState.value)
}
private fun loadFee() {
modelScope.launch {
val isShowLoading = (uiState.value as? FeeUM.Content)?.feeSelectorUM !is FeeSelectorUM.Content
if (isShowLoading) {
_uiState.update(SendFeeLoadingTransformer)
}
callFeeUseCase().fold(
ifRight = {
_uiState.update(
SendFeeLoadedTransformer(
fees = it,
clickIntents = this@SendFeeModel,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
isFeeApproximate = isFeeApproximate(it.normal.amount.type),
),
)
updateFeeNotifications()
},
ifLeft = { feeError ->
if (isShowLoading) {
_uiState.update(SendFeeFailedTransformer(feeError))
}
updateFeeNotifications()
},
)
}.saveIn(feeJobHolder)
}
private fun checkLoadFee() {
modelScope.launch {
callFeeUseCase().fold(
ifRight = {
feeCheckReloadTrigger.callbackCheckResult(true)
_uiState.update(
SendFeeLoadedTransformer(
fees = it,
clickIntents = this@SendFeeModel,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
isFeeApproximate = isFeeApproximate(it.normal.amount.type),
),
)
updateFeeNotifications()
},
ifLeft = { feeError ->
feeCheckReloadTrigger.callbackCheckResult(false)
_uiState.update(SendFeeFailedTransformer(feeError))
sendFeeAlertFactory.getFeeUnreachableErrorState(::checkLoadFee)
updateFeeNotifications()
},
)
}.saveIn(feeJobHolder)
}
private suspend fun callFeeUseCase() = getFeeUseCase.invoke(
amount = params.sendAmount,
destination = params.destinationAddress,
userWallet = params.userWallet,
cryptoCurrency = cryptoCurrencyStatus.currency,
)
private fun isFeeApproximate(amountType: AmountType): Boolean {
val networkId = feeCryptoCurrencyStatus.currency.network.id
return isFeeApproximateUseCase(
networkId = networkId,
amountType = amountType,
)
}
private fun updateFeeNotifications() {
_uiState.update(
SendFeeNotificationsTransformer(
cryptoCurrencyName = cryptoCurrencyStatus.currency.name,
onFeeReload = ::feeReload,
),
)
}
private fun navigate() {
saveResult()
router.pop()
}
private fun configFeeNavigation() {
val params = params as? SendFeeComponentParams.FeeParams ?: return
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).onEach { (_, _) ->
// todo
// params.callback.onNavigationResult(
// NavigationUM.Content(
// title = resourceReference(R.string.common_fee_selector_title),
// subtitle = null,
// backIconRes = R.drawable.ic_back_24,
// backIconClick = router::pop,
// primaryButton = ButtonsUM.PrimaryButtonUM(
// text = resourceReference(R.string.common_continue),
// isEnabled = state.isPrimaryButtonEnabled,
// onClick = ::onNextClick,
// ),
// prevButton = null,
// secondaryPairButtonsUM = null,
// ),
// )
}.launchIn(modelScope)
}
private companion object {
const val RU_LOCALE = "ru"
const val EN_LOCALE = "en"
const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/"
const val FEE_READ_MORE_URL_SECOND_PART = "/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/"
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.features.send.v2.subcomponents.fee.model.converters
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
internal class FeeConverter(
clickIntents: SendFeeClickIntents,
appCurrency: AppCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus,
private val fees: TransactionFee,
) : Converter<FeeConverter.Data, Fee> {
private val customFeeConverter = SendFeeCustomFieldConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
normalFee = fees.normal,
)
override fun convert(value: Data): Fee {
return when (fees) {
is TransactionFee.Choosable -> {
when (value.feeType) {
FeeType.Slow -> fees.minimum
FeeType.Market -> fees.normal
FeeType.Fast -> fees.priority
FeeType.Custom -> customFeeConverter.convertBack(value.customValues)
}
}
is TransactionFee.Single ->
when (value.feeType) {
FeeType.Market -> fees.normal
FeeType.Custom -> customFeeConverter.convertBack(value.customValues)
else -> fees.normal
}
}
}
data class Data(
val customValues: ImmutableList<CustomFeeFieldUM>,
val feeType: FeeType,
)
}

View file

@ -0,0 +1,112 @@
package com.tangem.features.send.v2.subcomponents.fee.model.converters
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.bitcoin.BitcoinCustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.kaspa.KaspaCustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.utils.converter.TwoWayConverter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal class SendFeeCustomFieldConverter(
private val clickIntents: SendFeeClickIntents,
private val appCurrency: AppCurrency,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
private val normalFee: Fee,
) : TwoWayConverter<Fee, ImmutableList<CustomFeeFieldUM>> {
private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
EthereumCustomFeeConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
}
private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
BitcoinCustomFeeConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
}
private val kaspaCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
KaspaCustomFeeConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
}
override fun convert(value: Fee): ImmutableList<CustomFeeFieldUM> {
return when (value) {
is Fee.Ethereum -> ethereumCustomFeeConverter.convert(value)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.convert(value)
is Fee.Kaspa -> kaspaCustomFeeConverter.convert(value)
else -> persistentListOf()
}
}
override fun convertBack(value: ImmutableList<CustomFeeFieldUM>): Fee {
return if (value.isEmpty()) {
normalFee
} else {
when (normalFee) {
is Fee.Ethereum -> ethereumCustomFeeConverter.convertBack(normalFee = normalFee, value = value)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.convertBack(normalFee = normalFee, value = value)
is Fee.Kaspa -> kaspaCustomFeeConverter.convertBack(normalFee = normalFee, value = value)
else -> {
val customFee = value.firstOrNull()
Fee.Common(
normalFee.amount.copy(
value = customFee?.value?.parseToBigDecimal(customFee.decimals),
),
)
}
}
}
}
fun onValueChange(feeSelectorState: FeeSelectorUM.Content, index: Int, value: String) =
when (val fee = feeSelectorState.fees.normal) {
is Fee.Ethereum -> ethereumCustomFeeConverter.onValueChange(
feeValue = fee,
customValues = feeSelectorState.customValues,
index = index,
value = value,
)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.onValueChange(
customValues = feeSelectorState.customValues,
index = index,
value = value,
txSize = fee.txSize,
)
is Fee.Kaspa -> kaspaCustomFeeConverter.onValueChange(
customValues = feeSelectorState.customValues,
index = index,
value = value,
)
else -> feeSelectorState.customValues
}
fun tryAutoFixValue(feeSelectorState: FeeSelectorUM.Content) = when (feeSelectorState.fees) {
is TransactionFee.Choosable -> feeSelectorState.fees.minimum
is TransactionFee.Single -> feeSelectorState.fees.normal
}.let {
when (it) {
is Fee.Kaspa -> kaspaCustomFeeConverter.tryAutoFixValue(
minimumFee = it,
customValues = feeSelectorState.customValues,
)
else -> feeSelectorState.customValues
}
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
internal interface CustomFeeConverter<T : Fee> : Converter<T, ImmutableList<CustomFeeFieldUM>> {
fun convertBack(normalFee: T, value: ImmutableList<CustomFeeFieldUM>): T
}
internal fun MutableList<CustomFeeFieldUM>.setEmpty(index: Int) {
set(index, this[index].copy(value = ""))
}

View file

@ -0,0 +1,143 @@
package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.bitcoin
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import com.tangem.lib.crypto.BlockchainUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
internal class BitcoinCustomFeeConverter(
private val clickIntents: SendFeeClickIntents,
private val appCurrency: AppCurrency,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : CustomFeeConverter<Fee.Bitcoin> {
private val currencyStatus = feeCryptoCurrencyStatus.value
private val network = feeCryptoCurrencyStatus.currency.network.id.value
override fun convert(value: Fee.Bitcoin): ImmutableList<CustomFeeFieldUM> {
val feeValue = value.amount.value
return if (BlockchainUtils.isUseBitcoinFeeConverter(network)) {
persistentListOf(
CustomFeeFieldUM(
value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(),
decimals = value.amount.decimals,
symbol = value.amount.currencySymbol,
onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT_INDEX, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Companion.Next,
keyboardType = KeyboardType.Companion.Number,
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_bitcoin_custom_fee_footer),
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = feeValue,
appCurrency = appCurrency,
),
keyboardActions = KeyboardActions(),
isReadonly = true,
),
CustomFeeFieldUM(
value = toSatoshiPerByte(
amount = feeValue,
decimals = value.amount.decimals,
txSize = value.txSize,
).toString(),
decimals = SATOSHI_DECIMALS,
symbol = "",
title = resourceReference(R.string.send_satoshi_per_byte_title),
footer = resourceReference(R.string.send_satoshi_per_byte_text),
onValueChange = { clickIntents.onCustomFeeValueChange(FEE_SATOSHI_INDEX, it) },
keyboardOptions = KeyboardOptions(
imeAction = if (checkExceedBalance(
feeBalance = currencyStatus.amount,
feeAmount = feeValue,
)
) {
ImeAction.Companion.None
} else {
ImeAction.Companion.Done
},
keyboardType = KeyboardType.Companion.Number,
),
keyboardActions = KeyboardActions(
onDone = { clickIntents.onNextClick() },
),
),
)
} else {
persistentListOf()
}
}
override fun convertBack(normalFee: Fee.Bitcoin, value: ImmutableList<CustomFeeFieldUM>): Fee.Bitcoin {
val feeAmount = value[FEE_AMOUNT_INDEX].value.parseToBigDecimal(value[FEE_AMOUNT_INDEX].decimals)
val satoshiPerByte = value[FEE_SATOSHI_INDEX].value.parseToBigDecimal(value[FEE_SATOSHI_INDEX].decimals)
return normalFee.copy(
amount = normalFee.amount.copy(value = feeAmount),
satoshiPerByte = satoshiPerByte,
)
}
fun onValueChange(
customValues: ImmutableList<CustomFeeFieldUM>,
index: Int,
value: String,
txSize: BigDecimal,
): ImmutableList<CustomFeeFieldUM> {
val mutableCustomValues = customValues.toMutableList()
return mutableCustomValues.apply {
if (index == FEE_SATOSHI_INDEX) {
val newSatoshiPerKb = value.parseToBigDecimal(this[FEE_SATOSHI_INDEX].decimals)
val newFeeAmount = newSatoshiPerKb.multiply(txSize)
.movePointLeft(this[FEE_AMOUNT_INDEX].decimals)
.setScale(this[FEE_AMOUNT_INDEX].decimals, RoundingMode.DOWN)
set(
FEE_AMOUNT_INDEX,
this[FEE_AMOUNT_INDEX].copy(
value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT_INDEX].decimals),
label = getFiatReference(
rate = feeCryptoCurrencyStatus.value.fiatRate,
value = newFeeAmount,
appCurrency = appCurrency,
),
),
)
set(index, this[index].copy(value = value))
}
}.toImmutableList()
}
private fun toSatoshiPerByte(amount: BigDecimal?, decimals: Int, txSize: BigDecimal): BigDecimal? {
val newFeeAmount = amount?.movePointRight(decimals)
return newFeeAmount?.divide(
txSize,
SATOSHI_DECIMALS,
RoundingMode.HALF_UP,
)?.setScale(SATOSHI_DECIMALS, RoundingMode.HALF_UP)
}
private companion object {
private const val FEE_AMOUNT_INDEX = 0
private const val FEE_SATOSHI_INDEX = 1
private const val SATOSHI_DECIMALS = 0
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import kotlinx.collections.immutable.ImmutableList
/**
* Base ethereum custom fee converter
*
* @param T subtype of [Fee.Ethereum]
*
[REDACTED_AUTHOR]
*/
internal interface BaseEthereumCustomFeeConverter<T : Fee.Ethereum> : CustomFeeConverter<T> {
fun getGasLimitIndex(feeValue: T): Int
fun onValueChange(
feeValue: T,
customValues: ImmutableList<CustomFeeFieldUM>,
index: Int,
value: String,
): ImmutableList<CustomFeeFieldUM>
}

View file

@ -0,0 +1,126 @@
package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
internal class EthereumCustomFeeConverter(
private val clickIntents: SendFeeClickIntents,
private val appCurrency: AppCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : BaseEthereumCustomFeeConverter<Fee.Ethereum> {
private val currencyStatus = feeCryptoCurrencyStatus.value
private val legacyFeeConverter = EthereumLegacyCustomFeeConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
private val eipFeeConverter = EthereumEIPCustomFeeConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
override fun convert(value: Fee.Ethereum): ImmutableList<CustomFeeFieldUM> {
return buildList {
convertFeeValue(value).let(::add)
when (value) {
is Fee.Ethereum.EIP1559 -> eipFeeConverter.convert(value)
is Fee.Ethereum.Legacy -> legacyFeeConverter.convert(value)
}.let(::addAll)
convertGasLimitValue(value).let(::add)
}
.toImmutableList()
}
override fun convertBack(normalFee: Fee.Ethereum, value: ImmutableList<CustomFeeFieldUM>): Fee.Ethereum {
return when (normalFee) {
is Fee.Ethereum.EIP1559 -> eipFeeConverter.convertBack(normalFee = normalFee, value = value)
is Fee.Ethereum.Legacy -> legacyFeeConverter.convertBack(normalFee = normalFee, value = value)
}
}
override fun getGasLimitIndex(feeValue: Fee.Ethereum): Int {
return when (feeValue) {
is Fee.Ethereum.EIP1559 -> eipFeeConverter.getGasLimitIndex(feeValue)
is Fee.Ethereum.Legacy -> legacyFeeConverter.getGasLimitIndex(feeValue)
}
}
override fun onValueChange(
feeValue: Fee.Ethereum,
customValues: ImmutableList<CustomFeeFieldUM>,
index: Int,
value: String,
): ImmutableList<CustomFeeFieldUM> {
return when (feeValue) {
is Fee.Ethereum.EIP1559 -> eipFeeConverter.onValueChange(feeValue, customValues, index, value)
is Fee.Ethereum.Legacy -> legacyFeeConverter.onValueChange(feeValue, customValues, index, value)
}
}
private fun convertFeeValue(value: Fee.Ethereum): CustomFeeFieldUM {
val feeValue = value.amount.value
return CustomFeeFieldUM(
value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(),
decimals = value.amount.decimals,
symbol = value.amount.currencySymbol,
onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT, it) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_custom_amount_fee_footer),
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = feeValue,
appCurrency = appCurrency,
),
keyboardActions = KeyboardActions(),
)
}
private fun convertGasLimitValue(value: Fee.Ethereum): CustomFeeFieldUM {
val isExceedBalance = checkExceedBalance(feeBalance = currencyStatus.amount, feeAmount = value.amount.value)
return CustomFeeFieldUM(
value = value.gasLimit.toString(),
decimals = GAS_DECIMALS,
symbol = "",
title = resourceReference(R.string.send_gas_limit),
footer = resourceReference(R.string.send_gas_limit_footer),
onValueChange = { clickIntents.onCustomFeeValueChange(getGasLimitIndex(value), it) },
keyboardOptions = KeyboardOptions(
imeAction = if (isExceedBalance) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(
onDone = { clickIntents.onNextClick() },
),
)
}
companion object {
const val ETHEREUM_GAS_UNIT = "GWEI"
const val GIGA_DECIMALS = 9
const val GAS_DECIMALS = 0
const val FEE_AMOUNT = 0
}
}

View file

@ -0,0 +1,204 @@
package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.ETHEREUM_GAS_UNIT
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.FEE_AMOUNT
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GAS_DECIMALS
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GIGA_DECIMALS
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.setEmpty
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.RoundingMode
internal class EthereumEIPCustomFeeConverter(
private val clickIntents: SendFeeClickIntents,
private val appCurrency: AppCurrency,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : BaseEthereumCustomFeeConverter<Fee.Ethereum.EIP1559> {
private val currencyStatus = feeCryptoCurrencyStatus.value
override fun convert(value: Fee.Ethereum.EIP1559): ImmutableList<CustomFeeFieldUM> {
return persistentListOf(
CustomFeeFieldUM(
value = value.maxFeePerGas.toBigDecimal().movePointLeft(GIGA_DECIMALS).parseBigDecimal(GIGA_DECIMALS),
decimals = GIGA_DECIMALS,
symbol = ETHEREUM_GAS_UNIT,
title = resourceReference(R.string.send_custom_evm_max_fee),
footer = resourceReference(R.string.send_custom_evm_max_fee_footer),
onValueChange = { clickIntents.onCustomFeeValueChange(MAX_FEE, it) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
),
CustomFeeFieldUM(
value = value.priorityFee.toBigDecimal().movePointLeft(GIGA_DECIMALS).parseBigDecimal(GIGA_DECIMALS),
decimals = GIGA_DECIMALS,
symbol = ETHEREUM_GAS_UNIT,
title = resourceReference(R.string.send_custom_evm_priority_fee),
footer = resourceReference(R.string.send_custom_evm_priority_fee_footer),
onValueChange = { clickIntents.onCustomFeeValueChange(PRIORITY_FEE, it) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
),
)
}
override fun convertBack(
normalFee: Fee.Ethereum.EIP1559,
value: ImmutableList<CustomFeeFieldUM>,
): Fee.Ethereum.EIP1559 {
val feeAmount = value[FEE_AMOUNT].value.parseToBigDecimal(value[FEE_AMOUNT].decimals)
val maxFeeDecimals = value[MAX_FEE].decimals
val maxFee = value[MAX_FEE].value.parseToBigDecimal(maxFeeDecimals)
.movePointRight(maxFeeDecimals)
.toBigInteger()
val priorityFeeDecimals = value[PRIORITY_FEE].decimals
val priorityFee = value[PRIORITY_FEE].value.parseToBigDecimal(priorityFeeDecimals)
.movePointRight(priorityFeeDecimals)
.toBigInteger()
val gasLimit = value[GAS_LIMIT].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger()
return normalFee.copy(
amount = normalFee.amount.copy(value = feeAmount),
maxFeePerGas = maxFee,
priorityFee = priorityFee,
gasLimit = gasLimit,
)
}
override fun getGasLimitIndex(feeValue: Fee.Ethereum.EIP1559): Int = GAS_LIMIT
override fun onValueChange(
feeValue: Fee.Ethereum.EIP1559,
customValues: ImmutableList<CustomFeeFieldUM>,
index: Int,
value: String,
): ImmutableList<CustomFeeFieldUM> {
val mutableCustomValues = customValues.toMutableList()
return mutableCustomValues.apply {
when (index) {
FEE_AMOUNT -> setOnAmountChange(value, index)
MAX_FEE -> setOnMaxFeeChange(value, index)
GAS_LIMIT -> setOnGasLimitChange(value, index)
else -> set(index, this[index].copy(value = value))
}
}.toImmutableList()
}
private fun MutableList<CustomFeeFieldUM>.setOnAmountChange(value: String, index: Int) {
val gasLimit = this[GAS_LIMIT].value.parseToBigDecimal(this[GAS_LIMIT].decimals)
if (value.isBlank()) {
setEmpty(FEE_AMOUNT)
setEmpty(MAX_FEE)
} else {
val newFeeAmountDecimal = value.parseToBigDecimal(this[FEE_AMOUNT].decimals)
val newFeeAmount = newFeeAmountDecimal.movePointRight(GIGA_DECIMALS) // from ETH to GWEI
val newMaxFee = newFeeAmount.divide(gasLimit, this[MAX_FEE].decimals, RoundingMode.HALF_UP)
set(
index = MAX_FEE,
element = this[MAX_FEE].copy(value = newMaxFee.parseBigDecimal(this[MAX_FEE].decimals)),
)
set(
index = index,
element = this[index].copy(
value = value,
label = getFiatReference(
rate = feeCryptoCurrencyStatus.value.fiatRate,
value = newFeeAmountDecimal,
appCurrency = appCurrency,
),
),
)
}
}
private fun MutableList<CustomFeeFieldUM>.setOnMaxFeeChange(value: String, index: Int) {
val gasLimit = this[GAS_LIMIT].value.parseToBigDecimal(this[GAS_LIMIT].decimals)
if (value.isBlank()) {
setEmpty(FEE_AMOUNT)
setEmpty(MAX_FEE)
} else {
val newMaxFee = value.parseToBigDecimal(this[MAX_FEE].decimals).movePointLeft(this[MAX_FEE].decimals)
val newFeeAmount = gasLimit * newMaxFee
set(
FEE_AMOUNT,
this[FEE_AMOUNT].copy(
value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT].decimals),
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = newFeeAmount,
appCurrency = appCurrency,
),
),
)
set(index, this[index].copy(value = value))
}
}
private fun MutableList<CustomFeeFieldUM>.setOnGasLimitChange(value: String, index: Int) {
if (value.isBlank()) {
setEmpty(FEE_AMOUNT)
setEmpty(GAS_LIMIT)
} else {
val newGasLimit = value.parseToBigDecimal(this[GAS_LIMIT].decimals)
val maxFee = this[MAX_FEE].value.parseToBigDecimal(this[MAX_FEE].decimals)
.movePointLeft(this[MAX_FEE].decimals) // from GWEI to ETH
val newFeeAmount = newGasLimit * maxFee
set(
index = FEE_AMOUNT,
element = this[FEE_AMOUNT].copy(
value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT].decimals),
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = newFeeAmount,
appCurrency = appCurrency,
),
),
)
val isNotExceedBalance = checkExceedBalance(
feeBalance = currencyStatus.amount,
feeAmount = newFeeAmount,
)
set(
index = index,
element = this[index].copy(
value = value,
keyboardOptions = KeyboardOptions(
imeAction = if (!isNotExceedBalance) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
),
)
}
}
private companion object {
const val MAX_FEE = 1
const val PRIORITY_FEE = 2
const val GAS_LIMIT = 3
}
}

View file

@ -0,0 +1,181 @@
package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.ETHEREUM_GAS_UNIT
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.FEE_AMOUNT
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GAS_DECIMALS
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GIGA_DECIMALS
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.setEmpty
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.RoundingMode
internal class EthereumLegacyCustomFeeConverter(
private val clickIntents: SendFeeClickIntents,
private val appCurrency: AppCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : BaseEthereumCustomFeeConverter<Fee.Ethereum.Legacy> {
private val currencyStatus = feeCryptoCurrencyStatus.value
override fun convert(value: Fee.Ethereum.Legacy): ImmutableList<CustomFeeFieldUM> {
return persistentListOf(
CustomFeeFieldUM(
value = value.gasPrice.toBigDecimal().movePointLeft(GIGA_DECIMALS).parseBigDecimal(GIGA_DECIMALS),
decimals = GIGA_DECIMALS,
symbol = ETHEREUM_GAS_UNIT,
title = resourceReference(R.string.send_gas_price),
footer = resourceReference(R.string.send_gas_price_footer),
onValueChange = { clickIntents.onCustomFeeValueChange(GAS_PRICE, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
),
)
}
override fun convertBack(
normalFee: Fee.Ethereum.Legacy,
value: ImmutableList<CustomFeeFieldUM>,
): Fee.Ethereum.Legacy {
val feeAmount = value[FEE_AMOUNT].value.parseToBigDecimal(value[FEE_AMOUNT].decimals)
val gasPrice = value[GAS_PRICE].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger()
val gasLimit = value[GAS_LIMIT].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger()
return normalFee.copy(
amount = normalFee.amount.copy(value = feeAmount),
gasPrice = gasPrice,
gasLimit = gasLimit,
)
}
override fun getGasLimitIndex(feeValue: Fee.Ethereum.Legacy): Int = GAS_LIMIT
override fun onValueChange(
feeValue: Fee.Ethereum.Legacy,
customValues: ImmutableList<CustomFeeFieldUM>,
index: Int,
value: String,
): ImmutableList<CustomFeeFieldUM> {
val mutableCustomValues = customValues.toMutableList()
return mutableCustomValues.apply {
when (index) {
FEE_AMOUNT -> setOnAmountChange(value, index)
GAS_PRICE -> setOnGasPriceChange(value, index)
GAS_LIMIT -> setOnGasLimitChange(value, index)
else -> set(index, this[index].copy(value = value))
}
}.toImmutableList()
}
private fun MutableList<CustomFeeFieldUM>.setOnAmountChange(value: String, index: Int) {
val gasLimit = this[GAS_LIMIT].value.parseToBigDecimal(this[GAS_LIMIT].decimals)
if (value.isBlank()) {
setEmpty(FEE_AMOUNT)
setEmpty(GAS_PRICE)
} else {
val newFeeAmountDecimal = value.parseToBigDecimal(this[FEE_AMOUNT].decimals)
val newFeeAmount = newFeeAmountDecimal.movePointRight(this[GAS_PRICE].decimals) // from ETH to GWEI
val newGasPrice = newFeeAmount.divide(gasLimit, this[GAS_PRICE].decimals, RoundingMode.HALF_UP)
set(GAS_PRICE, this[GAS_PRICE].copy(value = newGasPrice.parseBigDecimal(this[GAS_PRICE].decimals)))
set(
index,
this[index].copy(
value = value,
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = newFeeAmountDecimal,
appCurrency = appCurrency,
),
),
)
}
}
private fun MutableList<CustomFeeFieldUM>.setOnGasPriceChange(value: String, index: Int) {
val gasLimit = this[GAS_LIMIT].value.parseToBigDecimal(this[GAS_LIMIT].decimals)
if (value.isBlank()) {
setEmpty(FEE_AMOUNT)
setEmpty(GAS_PRICE)
} else {
val newGasPrice = value.parseToBigDecimal(this[GAS_PRICE].decimals)
.movePointLeft(this[GAS_PRICE].decimals) // from GWEI to ETH
val newFeeAmount = gasLimit * newGasPrice
set(
FEE_AMOUNT,
this[FEE_AMOUNT].copy(
value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT].decimals),
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = newFeeAmount,
appCurrency = appCurrency,
),
),
)
set(index, this[index].copy(value = value))
}
}
private fun MutableList<CustomFeeFieldUM>.setOnGasLimitChange(value: String, index: Int) {
if (value.isBlank()) {
setEmpty(FEE_AMOUNT)
setEmpty(GAS_LIMIT)
} else {
val newGasLimit = value.parseToBigDecimal(this[GAS_LIMIT].decimals)
val gasPrice = this[GAS_PRICE].value.parseToBigDecimal(this[GAS_PRICE].decimals)
.movePointLeft(this[GAS_PRICE].decimals) // from GWEI to ETH
val newFeeAmount = newGasLimit * gasPrice
set(
index = FEE_AMOUNT,
element = this[FEE_AMOUNT].copy(
value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT].decimals),
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = newFeeAmount,
appCurrency = appCurrency,
),
),
)
val isNotExceedBalance = checkExceedBalance(
feeBalance = currencyStatus.amount,
feeAmount = newFeeAmount,
)
set(
index = index,
element = this[index].copy(
value = value,
keyboardOptions = KeyboardOptions(
imeAction = if (!isNotExceedBalance) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
),
)
}
}
private companion object {
const val GAS_PRICE = 1
const val GAS_LIMIT = 2
}
}

View file

@ -0,0 +1,131 @@
package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.kaspa
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.RoundingMode
internal class KaspaCustomFeeConverter(
private val clickIntents: SendFeeClickIntents,
private val appCurrency: AppCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : CustomFeeConverter<Fee.Kaspa> {
private val currencyStatus = feeCryptoCurrencyStatus.value
override fun convert(value: Fee.Kaspa): ImmutableList<CustomFeeFieldUM> {
val feeValue = value.amount.value
return persistentListOf(
CustomFeeFieldUM(
value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(),
decimals = value.amount.decimals,
symbol = value.amount.currencySymbol,
onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT_INDEX, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Companion.Next,
keyboardType = KeyboardType.Companion.Number,
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_custom_amount_fee_footer),
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = feeValue,
appCurrency = appCurrency,
),
keyboardActions = KeyboardActions(),
),
)
}
override fun convertBack(normalFee: Fee.Kaspa, value: ImmutableList<CustomFeeFieldUM>): Fee.Kaspa {
val decimals = value[FEE_AMOUNT_INDEX].decimals
val feeAmount = value[FEE_AMOUNT_INDEX].value.parseToBigDecimal(decimals)
return normalFee.copy(
amount = normalFee.amount.copy(value = feeAmount),
mass = normalFee.mass,
feeRate = feeAmount
.divide(normalFee.mass.toBigDecimal(), decimals, RoundingMode.HALF_UP)
.movePointRight(decimals)
.toBigInteger(),
)
}
fun onValueChange(
customValues: ImmutableList<CustomFeeFieldUM>,
index: Int,
value: String,
): ImmutableList<CustomFeeFieldUM> {
val mutableCustomValues = customValues.toMutableList()
return mutableCustomValues.apply {
when (index) {
FEE_AMOUNT_INDEX -> {
val valueDecimal = value.parseToBigDecimal(this[FEE_AMOUNT_INDEX].decimals)
set(
index,
this[index].copy(
value = value,
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = valueDecimal,
appCurrency = appCurrency,
),
),
)
}
}
}.toImmutableList()
}
fun tryAutoFixValue(
minimumFee: Fee.Kaspa,
customValues: ImmutableList<CustomFeeFieldUM>,
): ImmutableList<CustomFeeFieldUM> {
val mutableCustomValues = customValues.toMutableList()
val minimumFeeAmountValue = minimumFee.amount.value
return mutableCustomValues.apply {
// check that there is reveal transaction info (= krc-20 token transfer)
// return without changes otherwise
if (minimumFee.revealTransactionFee != null && minimumFeeAmountValue != null) {
getOrNull(FEE_AMOUNT_INDEX)?.let {
val valueDecimal = it.value.parseToBigDecimal(it.decimals)
// krc-20 transaction will be failed if custom fee value is less than minimum,
// so we set value to minimum in this case
if (valueDecimal < minimumFee.amount.value) {
val fixedValue = minimumFeeAmountValue.parseBigDecimal(it.decimals)
set(
FEE_AMOUNT_INDEX,
it.copy(
value = fixedValue,
label = getFiatReference(
rate = currencyStatus.fiatRate,
value = valueDecimal,
appCurrency = appCurrency,
),
),
)
}
}
}
}.toImmutableList()
}
private companion object {
private const val FEE_AMOUNT_INDEX = 0
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.send.v2.subcomponents.fee.model.transformers
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.model.converters.SendFeeCustomFieldConverter
import com.tangem.utils.transformer.Transformer
internal class SendFeeCustomAutoFixTransformer(
private val clickIntents: SendFeeClickIntents,
private val appCurrency: AppCurrency,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : Transformer<FeeUM> {
override fun transform(prevState: FeeUM): FeeUM {
val state = prevState as? FeeUM.Content ?: return prevState
val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content ?: return state
val customFeeConverter = SendFeeCustomFieldConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
normalFee = feeSelectorUM.fees.normal,
)
return when (feeSelectorUM.selectedType) {
FeeType.Slow,
FeeType.Market,
FeeType.Fast,
-> state
FeeType.Custom -> {
val updatedCustomValues = customFeeConverter.tryAutoFixValue(feeSelectorUM)
state.copy(
feeSelectorUM = feeSelectorUM.copy(
customValues = updatedCustomValues,
selectedFee = customFeeConverter.convertBack(updatedCustomValues),
),
)
}
}
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.send.v2.subcomponents.fee.model.transformers
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.model.converters.SendFeeCustomFieldConverter
import com.tangem.utils.transformer.Transformer
internal class SendFeeCustomValueChangeTransformer(
private val index: Int,
private val value: String,
private val clickIntents: SendFeeClickIntents,
private val appCurrency: AppCurrency,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : Transformer<FeeUM> {
override fun transform(prevState: FeeUM): FeeUM {
val state = prevState as? FeeUM.Content ?: return prevState
val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content ?: return state
val customFeeConverter = SendFeeCustomFieldConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
normalFee = feeSelectorUM.fees.normal,
)
val updatedCustomValues = customFeeConverter.onValueChange(feeSelectorUM, index, value)
return state.copy(
feeSelectorUM = feeSelectorUM.copy(
customValues = updatedCustomValues,
selectedFee = customFeeConverter.convertBack(updatedCustomValues),
),
)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.subcomponents.fee.model.transformers
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.utils.transformer.Transformer
internal class SendFeeFailedTransformer(
private val error: GetFeeError,
) : Transformer<FeeUM> {
override fun transform(prevState: FeeUM): FeeUM {
val state = prevState as? FeeUM.Content ?: return prevState
return state.copy(
feeSelectorUM = FeeSelectorUM.Error(error),
isPrimaryButtonEnabled = false,
)
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.send.v2.subcomponents.fee.model.transformers
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
internal class SendFeeInitialStateTransformer(
cryptoCurrencyStatus: CryptoCurrencyStatus,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
) : Transformer<FeeUM> {
private val cryptoCurrency = cryptoCurrencyStatus.currency
override fun transform(prevState: FeeUM): FeeUM {
return FeeUM.Content(
isPrimaryButtonEnabled = false,
feeSelectorUM = FeeSelectorUM.Loading,
notifications = persistentListOf(),
rate = feeCryptoCurrencyStatus.value.fiatRate,
appCurrency = appCurrency,
isFeeApproximate = false,
isCustomSelected = false,
isFeeConvertibleToFiat = feeCryptoCurrencyStatus.currency.network.hasFiatFeeRate,
isTronToken = cryptoCurrency is CryptoCurrency.Token &&
isTron(cryptoCurrency.network.id.value),
)
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.send.v2.subcomponents.fee.model.transformers
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.fee.model.converters.FeeConverter
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.fee.model.converters.SendFeeCustomFieldConverter
import com.tangem.utils.transformer.Transformer
internal class SendFeeLoadedTransformer(
clickIntents: SendFeeClickIntents,
appCurrency: AppCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus,
private val fees: TransactionFee,
private val isFeeApproximate: Boolean,
) : Transformer<FeeUM> {
private val customFeeFieldConverter = SendFeeCustomFieldConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
normalFee = fees.normal,
)
private val feeConverter = FeeConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fees = fees,
)
override fun transform(prevState: FeeUM): FeeUM {
val state = prevState as? FeeUM.Content ?: return prevState
val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content
val updatedFeeSelector = if (feeSelectorUM == null) {
FeeSelectorUM.Content(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
selectedFee = fees.normal,
)
} else {
FeeSelectorUM.Content(
fees = fees,
customValues = feeSelectorUM.customValues,
selectedType = feeSelectorUM.selectedType,
selectedFee = feeConverter.convert(
FeeConverter.Data(
feeType = feeSelectorUM.selectedType,
customValues = feeSelectorUM.customValues,
),
),
)
}
return state.copy(
feeSelectorUM = updatedFeeSelector,
isFeeApproximate = isFeeApproximate,
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.send.v2.subcomponents.fee.model.transformers
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
internal object SendFeeLoadingTransformer : Transformer<FeeUM> {
override fun transform(prevState: FeeUM): FeeUM {
val state = prevState as? FeeUM.Content ?: return prevState
return state.copy(
feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content ?: FeeSelectorUM.Loading,
notifications = persistentListOf(),
isPrimaryButtonEnabled = false,
)
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.features.send.v2.subcomponents.fee.model.transformers
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.utils.extensions.isZero
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
internal class SendFeeNotificationsTransformer(
private val cryptoCurrencyName: String,
private val onFeeReload: () -> Unit,
) : Transformer<FeeUM> {
override fun transform(prevState: FeeUM): FeeUM {
val state = prevState as? FeeUM.Content ?: return prevState
val notifications = state.getNotifications()
return state.copy(
notifications = notifications,
isPrimaryButtonEnabled = state.isPrimaryButtonEnabled(notifications),
)
}
private fun FeeUM.Content.getNotifications() = buildList {
addFeeUnreachableNotification(
feeError = (feeSelectorUM as? FeeSelectorUM.Error)?.error,
tokenName = cryptoCurrencyName,
onReload = onFeeReload,
)
}.toImmutableList()
private fun FeeUM.Content.isPrimaryButtonEnabled(notifications: ImmutableList<NotificationUM>): Boolean {
val feeSelectorState = feeSelectorUM as? FeeSelectorUM.Content ?: return false
val customValue = feeSelectorState.customValues.firstOrNull()
val isNotCustom = feeSelectorState.selectedType != FeeType.Custom
val isNotEmptyCustom = if (customValue != null) {
!customValue.value.parseToBigDecimal(customValue.decimals).isZero() && !isNotCustom
} else {
false
}
val noErrors = notifications.none { it is NotificationUM.Error }
return noErrors && (isNotEmptyCustom || isNotCustom)
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.send.v2.subcomponents.fee.model.transformers
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.fee.model.converters.FeeConverter
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.utils.transformer.Transformer
internal class SendFeeSelectTransformer(
private val feeType: FeeType,
private val clickIntents: SendFeeClickIntents,
private val appCurrency: AppCurrency,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : Transformer<FeeUM> {
override fun transform(prevState: FeeUM): FeeUM {
val state = prevState as? FeeUM.Content ?: return prevState
val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content ?: return state
val feeConverter = FeeConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fees = feeSelectorUM.fees,
)
val updatedFeeSelectorState = feeSelectorUM.copy(
selectedType = feeType,
selectedFee = feeConverter.convert(
FeeConverter.Data(
customValues = feeSelectorUM.customValues,
feeType = feeType,
),
),
)
val isCustomFeeWasSelected = state.isCustomSelected || updatedFeeSelectorState.selectedType == FeeType.Custom
return state.copy(
isCustomSelected = isCustomFeeWasSelected,
feeSelectorUM = updatedFeeSelectorState,
)
}
}

View file

@ -0,0 +1,132 @@
package com.tangem.features.send.v2.subcomponents.fee.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
@Composable
internal fun FeeBlock(feeUM: FeeUM, isClickEnabled: Boolean, onClick: () -> Unit) {
val feeUM = feeUM as? FeeUM.Content ?: return
val feeSelectorUM = feeUM.feeSelectorUM as? FeeSelectorUM.Content
val isEditingDisabled = feeSelectorUM?.fees is TransactionFee.Single
val backgroundColor = if (isEditingDisabled) {
TangemTheme.colors.button.disabled
} else {
TangemTheme.colors.background.action
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor)
.clickable(enabled = isClickEnabled && !isEditingDisabled, onClick = onClick)
.padding(12.dp),
) {
Text(
text = stringResourceSafe(R.string.common_network_fee_title),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.secondary,
)
Box(
modifier = Modifier.padding(top = 8.dp),
) {
val feeSelectorUM = feeUM.feeSelectorUM
val feeAmount = (feeSelectorUM as? FeeSelectorUM.Content)?.selectedFee?.amount
val (title, icon) = if (feeSelectorUM is FeeSelectorUM.Content) {
when (feeSelectorUM.selectedType) {
FeeType.Slow -> R.string.common_fee_selector_option_slow to R.drawable.ic_tortoise_24
FeeType.Market -> R.string.common_fee_selector_option_market to R.drawable.ic_bird_24
FeeType.Fast -> R.string.common_fee_selector_option_fast to R.drawable.ic_hare_24
FeeType.Custom -> R.string.common_custom to R.drawable.ic_edit_24
}
} else {
R.string.common_fee_selector_option_market to R.drawable.ic_bird_24
}
SelectorRowItem(
titleRes = title,
iconRes = icon,
preDot = stringReference(
feeAmount?.value.format {
crypto(
symbol = feeAmount?.currencySymbol.orEmpty(),
decimals = feeAmount?.decimals ?: 0,
).fee(canBeLower = feeUM.isFeeApproximate)
},
),
postDot = if (feeUM.isFeeConvertibleToFiat) {
getFiatReference(feeAmount?.value, feeUM.rate, feeUM.appCurrency)
} else {
null
},
ellipsizeOffset = feeAmount?.currencySymbol?.length,
isSelected = true,
showDivider = false,
showSelectedAppearance = false,
paddingValues = PaddingValues(),
)
FeeLoading(feeSelectorUM)
FeeError(feeSelectorUM)
}
}
}
@Composable
private fun BoxScope.FeeLoading(feeSelectorUM: FeeSelectorUM) {
AnimatedContent(
targetState = feeSelectorUM,
label = "Fee Loading State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it == FeeSelectorUM.Loading) {
RectangleShimmer(
radius = 3.dp,
modifier = Modifier.size(
height = 12.dp,
width = 90.dp,
),
)
}
}
}
@Composable
private fun BoxScope.FeeError(feeSelectorUM: FeeSelectorUM) {
AnimatedContent(
targetState = feeSelectorUM,
label = "Fee Error State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it is FeeSelectorUM.Error) {
Text(
text = EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
)
}
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.features.send.v2.subcomponents.fee.ui
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.containers.FooterContainer
import com.tangem.core.ui.components.inputrow.InputRowEnterAmount
import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmount
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun SendCustomFee(
customValues: ImmutableList<CustomFeeFieldUM>,
selectedFee: FeeType,
hasNotifications: Boolean,
onValueChange: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = selectedFee == FeeType.Custom && customValues.isNotEmpty(),
label = "Custom Fee Selected Animation",
enter = expandVertically().plus(fadeIn()),
exit = shrinkVertically().plus(fadeOut()),
) {
val bottomPadding = if (hasNotifications) 12.dp else 0.dp
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.padding(bottom = bottomPadding),
) {
repeat(customValues.size) { index ->
val value = customValues[index]
FooterContainer(
footer = value.footer,
) {
if (value.label != null) {
InputRowEnterInfoAmount(
text = value.value,
decimals = value.decimals,
symbol = value.symbol,
title = value.title,
info = value.label,
keyboardOptions = value.keyboardOptions,
keyboardActions = value.keyboardActions,
onValueChange = { onValueChange(index, it) },
showDivider = false,
isReadOnly = value.isReadonly,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
} else {
InputRowEnterAmount(
text = value.value,
decimals = value.decimals,
title = value.title,
symbol = value.symbol,
onValueChange = { onValueChange(index, it) },
keyboardOptions = value.keyboardOptions,
keyboardActions = value.keyboardActions,
showDivider = false,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
}
}
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.send.v2.subcomponents.fee.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
import com.tangem.features.send.v2.subcomponents.notifications
private const val FEE_SELECTOR_KEY = "FEE_SELECTOR_KEY"
private const val FEE_CUSTOM_KEY = "FEE_CUSTOM_KEY"
@Composable
internal fun SendFeeContent(state: FeeUM, clickIntents: SendFeeClickIntents) {
if (state !is FeeUM.Content) return
val notifications = state.notifications
val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content
val isCustomSelected = feeSelectorUM?.selectedType == FeeType.Custom
val hasNotifications = notifications.isNotEmpty()
LazyColumn(
modifier = Modifier // Do not put fillMaxSize() in here
.background(TangemTheme.colors.background.tertiary)
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
) {
feeSelector(state, clickIntents)
if (feeSelectorUM != null) {
customFee(
feeSelectorUM = feeSelectorUM,
onValueChange = clickIntents::onCustomFeeValueChange,
hasNotifications = hasNotifications,
)
}
notifications(notifications = notifications, hasPaddingAbove = isCustomSelected)
}
}
private fun LazyListScope.feeSelector(state: FeeUM.Content, clickIntents: SendFeeClickIntents) {
item(key = FEE_SELECTOR_KEY) {
SendSpeedSelector(
state = state,
clickIntents = clickIntents,
modifier = Modifier.animateItem(),
)
}
}
internal fun LazyListScope.customFee(
feeSelectorUM: FeeSelectorUM.Content,
hasNotifications: Boolean,
onValueChange: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
item(key = FEE_CUSTOM_KEY) {
SendCustomFee(
customValues = feeSelectorUM.customValues,
selectedFee = feeSelectorUM.selectedType,
hasNotifications = hasNotifications,
onValueChange = onValueChange,
modifier = modifier
.fillMaxWidth()
.animateItem()
.background(TangemTheme.colors.background.tertiary)
.padding(top = 12.dp),
)
}
}

View file

@ -0,0 +1,102 @@
package com.tangem.features.send.v2.subcomponents.fee.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents
@Suppress("LongMethod")
@Composable
internal fun SendSpeedSelector(
state: FeeUM.Content,
clickIntents: SendFeeClickIntents,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
) {
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_slow,
iconRes = R.drawable.ic_tortoise_24,
feeType = FeeType.Slow,
state = state,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.Slow) },
)
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_market,
iconRes = R.drawable.ic_bird_24,
feeType = FeeType.Market,
state = state,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.Market) },
)
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_fast,
iconRes = R.drawable.ic_hare_24,
feeType = FeeType.Fast,
state = state,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.Fast) },
)
SendSpeedSelectorItem(
titleRes = R.string.common_custom,
iconRes = R.drawable.ic_edit_24,
feeType = FeeType.Custom,
state = state,
onSelect = { clickIntents.onFeeSelectorClick(FeeType.Custom) },
)
}
FooterText(clickIntents::onReadMoreClick)
}
}
@Composable
private fun FooterText(onReadMoreClick: () -> Unit) {
val linkText = stringResourceSafe(R.string.common_read_more)
val fullString = stringResourceSafe(R.string.common_fee_selector_footer, linkText)
val linkTextPosition = fullString.length - linkText.length
val defaultStyle = TangemTheme.colors.text.tertiary
val linkStyle = TangemTheme.colors.text.accent
val annotatedString = remember(defaultStyle, linkStyle) {
buildAnnotatedString {
withStyle(SpanStyle(defaultStyle)) {
append(fullString.substring(0, linkTextPosition))
}
withStyle(SpanStyle(linkStyle)) {
append(fullString.substring(linkTextPosition, fullString.length))
}
}
}
val click = { i: Int ->
val readMoreStyle = requireNotNull(annotatedString.spanStyles.getOrNull(1))
if (i in readMoreStyle.start..readMoreStyle.end) {
onReadMoreClick()
}
}
ClickableText(
text = annotatedString,
style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start),
modifier = Modifier.padding(top = 8.dp),
onClick = click,
)
}

View file

@ -0,0 +1,152 @@
package com.tangem.features.send.v2.subcomponents.fee.ui
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
@Composable
internal fun SendSpeedSelectorItem(
@StringRes titleRes: Int,
@DrawableRes iconRes: Int,
feeType: FeeType,
state: FeeUM.Content,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
val feeSelectorState = state.feeSelectorUM
val content = feeSelectorState as? FeeSelectorUM.Content
val amount = content?.getAmount(feeType)
val (showDivider, isVisible) = content.getDividerAndVisibility(feeType)
AnimatedVisibility(
visible = isVisible,
label = "Fee Selector Visibility Animation",
enter = expandVertically().plus(fadeIn()),
exit = shrinkVertically().plus(fadeOut()),
) {
Box(
modifier = modifier
.fillMaxWidth()
.clickable { onSelect() },
) {
SelectorRowItem(
titleRes = titleRes,
iconRes = iconRes,
onSelect = onSelect,
modifier = modifier,
preDot = stringReference(
amount?.value.format {
crypto(
symbol = amount?.currencySymbol.orEmpty(),
decimals = amount?.decimals ?: 0,
).fee(canBeLower = state.isFeeApproximate)
},
),
postDot = if (state.isFeeConvertibleToFiat) {
getFiatReference(amount?.value, state.rate, state.appCurrency)
} else {
null
},
ellipsizeOffset = amount?.currencySymbol?.length,
isSelected = content?.selectedType == feeType,
showDivider = showDivider,
)
FeeLoading(feeSelectorState)
FeeError(feeSelectorState)
}
}
}
@Composable
private fun FeeLoading(feeSelectorState: FeeSelectorUM) {
Row {
SpacerWMax()
AnimatedVisibility(
visible = feeSelectorState == FeeSelectorUM.Loading,
label = "Fee Loading State Change",
modifier = Modifier.align(Alignment.CenterVertically),
) {
RectangleShimmer(
radius = 3.dp,
modifier = Modifier
.padding(
vertical = 18.dp,
horizontal = 12.dp,
)
.size(
height = 12.dp,
width = 90.dp,
),
)
}
}
}
@Composable
private fun FeeError(feeSelectorState: FeeSelectorUM) {
Row {
SpacerWMax()
AnimatedVisibility(
visible = feeSelectorState is FeeSelectorUM.Error,
label = "Fee Error State Change",
modifier = Modifier.align(Alignment.CenterVertically),
) {
Text(
text = EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
modifier = Modifier
.padding(
vertical = 14.dp,
horizontal = 12.dp,
),
)
}
}
}
private fun FeeSelectorUM.Content.getAmount(feeType: FeeType): Amount? {
val choosableFees = fees as? TransactionFee.Choosable
val decimals = fees.normal.amount.decimals
val customValue = this.customValues.firstOrNull()?.value?.parseToBigDecimal(decimals)
val customAmount = fees.normal.amount.copy(value = customValue)
return when (feeType) {
FeeType.Slow -> choosableFees?.minimum?.amount
FeeType.Market -> fees.normal.amount
FeeType.Fast -> choosableFees?.priority?.amount
FeeType.Custom -> customAmount
}
}
private fun FeeSelectorUM.Content?.getDividerAndVisibility(feeType: FeeType): Pair<Boolean, Boolean> {
val hasCustomValues = !this?.customValues.isNullOrEmpty()
val isNotSingle = this?.fees !is TransactionFee.Single
return when (feeType) {
FeeType.Slow -> true to isNotSingle
FeeType.Market -> (isNotSingle || hasCustomValues) to true
FeeType.Fast -> hasCustomValues to isNotSingle
FeeType.Custom -> false to hasCustomValues
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.subcomponents.fee.ui.state
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
internal 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,44 @@
package com.tangem.features.send.v2.subcomponents.fee.ui.state
import androidx.compose.runtime.Stable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.transaction.error.GetFeeError
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
internal sealed class FeeSelectorUM {
data class Content(
val fees: TransactionFee,
val selectedType: FeeType = FeeType.Market,
val selectedFee: Fee?,
val customValues: ImmutableList<CustomFeeFieldUM> = persistentListOf(),
) : FeeSelectorUM()
data object Loading : FeeSelectorUM()
data class Error(
val error: GetFeeError,
) : FeeSelectorUM()
}
internal enum class FeeType {
Slow,
Market,
Fast,
Custom,
;
fun toAnalyticType(feeSelectorUM: FeeSelectorUM.Content): AnalyticsParam.FeeType = when (feeSelectorUM.fees) {
is TransactionFee.Single -> AnalyticsParam.FeeType.Fixed
is TransactionFee.Choosable -> when (feeSelectorUM.selectedType) {
Slow -> AnalyticsParam.FeeType.Min
Market -> AnalyticsParam.FeeType.Normal
Fast -> AnalyticsParam.FeeType.Max
Custom -> AnalyticsParam.FeeType.Custom
}
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.send.v2.subcomponents.fee.ui.state
import androidx.compose.runtime.Stable
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.domain.appcurrency.model.AppCurrency
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Stable
internal sealed class FeeUM {
abstract val isPrimaryButtonEnabled: Boolean
data class Content(
override val isPrimaryButtonEnabled: Boolean,
val feeSelectorUM: FeeSelectorUM,
val rate: BigDecimal?,
val appCurrency: AppCurrency,
val isFeeConvertibleToFiat: Boolean,
val isFeeApproximate: Boolean,
val isCustomSelected: Boolean,
val isTronToken: Boolean,
val customValues: ImmutableList<CustomFeeFieldUM> = persistentListOf(),
val notifications: ImmutableList<NotificationUM>,
val isEditingDisabled: Boolean = false,
) : FeeUM()
data class Empty(
override val isPrimaryButtonEnabled: Boolean = false,
) : FeeUM()
}