From eee3d40e31ff359115804db297c353436aad09c9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 27 Mar 2025 11:08:57 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../features/send/v2/di/SendModelModule.kt | 6 + .../send/v2/send/DefaultSendComponent.kt | 31 +- .../features/send/v2/send/model/SendModel.kt | 15 +- .../features/send/v2/send/ui/state/SendUM.kt | 2 + .../send/v2/subcomponents/Notifications.kt | 48 +++ .../destination/SendDestinationComponent.kt | 2 + .../fee/SendFeeBlockComponent.kt | 42 +++ .../v2/subcomponents/fee/SendFeeComponent.kt | 30 ++ .../fee/SendFeeComponentParams.kt | 47 +++ .../subcomponents/fee/SendFeeReloadTrigger.kt | 59 ++++ .../fee/analytics/SendFeeAnalyticEvents.kt | 39 +++ .../v2/subcomponents/fee/di/SendFeeModule.kt | 27 ++ .../subcomponents/fee/model/FeeCalculation.kt | 90 ++++++ .../fee/model/SendFeeAlertFactory.kt | 127 ++++++++ .../fee/model/SendFeeClickIntents.kt | 16 + .../subcomponents/fee/model/SendFeeModel.kt | 290 ++++++++++++++++++ .../fee/model/converters/FeeConverter.kt | 50 +++ .../converters/SendFeeCustomFieldConverter.kt | 112 +++++++ .../converters/custom/CustomFeeConverter.kt | 14 + .../bitcoin/BitcoinCustomFeeConverter.kt | 143 +++++++++ .../BaseEthereumCustomFeeConverter.kt | 25 ++ .../ethereum/EthereumCustomFeeConverter.kt | 126 ++++++++ .../ethereum/EthereumEIPCustomFeeConverter.kt | 204 ++++++++++++ .../EthereumLegacyCustomFeeConverter.kt | 181 +++++++++++ .../custom/kaspa/KaspaCustomFeeConverter.kt | 131 ++++++++ .../SendFeeCustomAutoFixTransformer.kt | 45 +++ .../SendFeeCustomValueChangeTransformer.kt | 39 +++ .../transformers/SendFeeFailedTransformer.kt | 20 ++ .../SendFeeInitialStateTransformer.kt | 34 ++ .../transformers/SendFeeLoadedTransformer.kt | 64 ++++ .../transformers/SendFeeLoadingTransformer.kt | 17 + .../SendFeeNotificationsTransformer.kt | 51 +++ .../transformers/SendFeeSelectTransformer.kt | 46 +++ .../send/v2/subcomponents/fee/ui/FeeBlock.kt | 132 ++++++++ .../v2/subcomponents/fee/ui/SendCustomFee.kt | 82 +++++ .../v2/subcomponents/fee/ui/SendFeeContent.kt | 79 +++++ .../subcomponents/fee/ui/SendSpeedSelector.kt | 102 ++++++ .../fee/ui/SendSpeedSelectorItem.kt | 152 +++++++++ .../fee/ui/state/CustomFeeFieldUM.kt | 20 ++ .../fee/ui/state/FeeSelectorUM.kt | 44 +++ .../v2/subcomponents/fee/ui/state/FeeUM.kt | 32 ++ 41 files changed, 2811 insertions(+), 5 deletions(-) create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/Notifications.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeBlockComponent.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeReloadTrigger.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/analytics/SendFeeAnalyticEvents.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/di/SendFeeModule.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/FeeCalculation.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomAutoFixTransformer.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomValueChangeTransformer.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeFailedTransformer.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeInitialStateTransformer.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadingTransformer.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNotificationsTransformer.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeSelectTransformer.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/FeeBlock.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelector.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelectorItem.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt create mode 100644 features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt index 488de424c8..60d45d9aa5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt @@ -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 } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt index 3a97b61648..3683819133 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt @@ -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(), + 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 diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 5c9840758f..b122c64efd 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -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(), ) } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt index 41cfc2f614..49a4f918a7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt @@ -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, ) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/Notifications.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/Notifications.kt new file mode 100644 index 0000000000..4704183c22 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/Notifications.kt @@ -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, + 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, + ) + }, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponent.kt index 3110982216..c67e2b6b74 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponent.kt @@ -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() diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeBlockComponent.kt new file mode 100644 index 0000000000..4190c4a79d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeBlockComponent.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt new file mode 100644 index 0000000000..b5a67be3a4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt new file mode 100644 index 0000000000..82c9f88081 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt @@ -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, + 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, + ) : SendFeeComponentParams() +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeReloadTrigger.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeReloadTrigger.kt new file mode 100644 index 0000000000..6502eb6fa2 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeReloadTrigger.kt @@ -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 + + /** 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 + + /** Flow return result of fee check update */ + val checkReloadResultFlow: Flow + + /** 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() + override val reloadTriggerFlow = _reloadTriggerFlow.asSharedFlow() + + private val _checkReloadTriggerFlow = MutableSharedFlow() + override val checkReloadTriggerFlow = _checkReloadTriggerFlow.asSharedFlow() + + private val _checkReloadResultFlow = MutableSharedFlow() + 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) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/analytics/SendFeeAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/analytics/SendFeeAnalyticEvents.kt new file mode 100644 index 0000000000..c65789aaa4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/analytics/SendFeeAnalyticEvents.kt @@ -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 = 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", + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/di/SendFeeModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/di/SendFeeModule.kt new file mode 100644 index 0000000000..27cc5cacd4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/di/SendFeeModule.kt @@ -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() + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/FeeCalculation.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/FeeCalculation.kt new file mode 100644 index 0000000000..939bb73a64 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/FeeCalculation.kt @@ -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 { + 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 +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt new file mode 100644 index 0000000000..6526312b27 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt @@ -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, + ) + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt new file mode 100644 index 0000000000..656699429a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt @@ -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() +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt new file mode 100644 index 0000000000..ae53c69895 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt @@ -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/" + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt new file mode 100644 index 0000000000..2a5863ed1d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt @@ -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 { + + 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, + val feeType: FeeType, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt new file mode 100644 index 0000000000..78304904a5 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt @@ -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> { + + 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 { + 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): 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 + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt new file mode 100644 index 0000000000..4250d48580 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt @@ -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 : Converter> { + fun convertBack(normalFee: T, value: ImmutableList): T +} + +internal fun MutableList.setEmpty(index: Int) { + set(index, this[index].copy(value = "")) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt new file mode 100644 index 0000000000..c2c024fab1 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt @@ -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 { + + private val currencyStatus = feeCryptoCurrencyStatus.value + private val network = feeCryptoCurrencyStatus.currency.network.id.value + + override fun convert(value: Fee.Bitcoin): ImmutableList { + 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): 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, + index: Int, + value: String, + txSize: BigDecimal, + ): ImmutableList { + 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 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt new file mode 100644 index 0000000000..c0828d3d60 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt @@ -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 : CustomFeeConverter { + + fun getGasLimitIndex(feeValue: T): Int + + fun onValueChange( + feeValue: T, + customValues: ImmutableList, + index: Int, + value: String, + ): ImmutableList +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt new file mode 100644 index 0000000000..ce2ba52261 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt @@ -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 { + + 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 { + 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): 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, + index: Int, + value: String, + ): ImmutableList { + 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 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt new file mode 100644 index 0000000000..2a7d389e13 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt @@ -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 { + + private val currencyStatus = feeCryptoCurrencyStatus.value + + override fun convert(value: Fee.Ethereum.EIP1559): ImmutableList { + 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, + ): 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, + index: Int, + value: String, + ): ImmutableList { + 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.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.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.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 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt new file mode 100644 index 0000000000..8b2f86beb7 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt @@ -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 { + + private val currencyStatus = feeCryptoCurrencyStatus.value + + override fun convert(value: Fee.Ethereum.Legacy): ImmutableList { + 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, + ): 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, + index: Int, + value: String, + ): ImmutableList { + 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.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.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.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 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt new file mode 100644 index 0000000000..2714e7d9f6 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt @@ -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 { + + private val currencyStatus = feeCryptoCurrencyStatus.value + + override fun convert(value: Fee.Kaspa): ImmutableList { + 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): 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, + index: Int, + value: String, + ): ImmutableList { + 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, + ): ImmutableList { + 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 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomAutoFixTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomAutoFixTransformer.kt new file mode 100644 index 0000000000..65d07205da --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomAutoFixTransformer.kt @@ -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 { + + 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), + ), + ) + } + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomValueChangeTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomValueChangeTransformer.kt new file mode 100644 index 0000000000..5d95767e59 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomValueChangeTransformer.kt @@ -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 { + + 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), + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeFailedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeFailedTransformer.kt new file mode 100644 index 0000000000..e3f728dd23 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeFailedTransformer.kt @@ -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 { + + override fun transform(prevState: FeeUM): FeeUM { + val state = prevState as? FeeUM.Content ?: return prevState + + return state.copy( + feeSelectorUM = FeeSelectorUM.Error(error), + isPrimaryButtonEnabled = false, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeInitialStateTransformer.kt new file mode 100644 index 0000000000..723147ce78 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeInitialStateTransformer.kt @@ -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 { + + 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), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt new file mode 100644 index 0000000000..b1a7fb7467 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt @@ -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 { + + 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, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadingTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadingTransformer.kt new file mode 100644 index 0000000000..96c3e3d00a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadingTransformer.kt @@ -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 { + 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, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNotificationsTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNotificationsTransformer.kt new file mode 100644 index 0000000000..d8416e2718 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNotificationsTransformer.kt @@ -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 { + + 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): 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) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeSelectTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeSelectTransformer.kt new file mode 100644 index 0000000000..6ae4b1b0d3 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeSelectTransformer.kt @@ -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 { + + 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, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/FeeBlock.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/FeeBlock.kt new file mode 100644 index 0000000000..b1c82b14af --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/FeeBlock.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt new file mode 100644 index 0000000000..cf31789446 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt @@ -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, + 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, + ), + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt new file mode 100644 index 0000000000..728f537a37 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt @@ -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), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelector.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelector.kt new file mode 100644 index 0000000000..0437760435 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelector.kt @@ -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, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelectorItem.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelectorItem.kt new file mode 100644 index 0000000000..3cc6d6042b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelectorItem.kt @@ -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 { + 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 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt new file mode 100644 index 0000000000..5b52d2c6b8 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt @@ -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, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt new file mode 100644 index 0000000000..a9f5aae1b4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt @@ -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 = 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 + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt new file mode 100644 index 0000000000..6bf499e1c4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt @@ -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 = persistentListOf(), + val notifications: ImmutableList, + val isEditingDisabled: Boolean = false, + ) : FeeUM() + + data class Empty( + override val isPrimaryButtonEnabled: Boolean = false, + ) : FeeUM() +} \ No newline at end of file