Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-11 18:55:37 +03:00
parent 330ae73357
commit e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions

1
features/send/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,91 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.send.impl"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.appCompat)
implementation(deps.androidx.paging.runtime)
/** Other dependencies */
implementation(deps.kotlin.immutable.collections)
implementation(deps.material)
implementation(deps.arrow.core)
implementation(deps.lifecycle.compose)
implementation(deps.jodatime)
implementation(deps.timber)
implementation(deps.reKotlin)
implementation(deps.kotlin.serialization)
/** Compose */
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.material3)
implementation(deps.compose.material)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.navigation)
implementation(deps.compose.navigation.hilt)
implementation(deps.compose.paging)
implementation(deps.compose.constraintLayout)
/** Tangem SDKs */
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(projects.core.navigation)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.datasource)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)
/** Libs */
implementation(projects.libs.crypto)
/** Domain modules */
implementation(projects.domain.models)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.transaction)
implementation(projects.domain.transaction.models)
implementation(projects.domain.card)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.feedback)
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.settings)
/** Feature modules */
implementation(projects.features.send.api)
implementation(projects.features.tokendetails.api)
implementation(projects.features.qrScanning.api)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.send.impl.di
import com.tangem.common.routing.AppRouter
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.DefaultSendRouter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped
/**
* DI module provides implementation of [SendRouter]
*/
@Module
@InstallIn(ActivityComponent::class)
internal object SendRouterModule {
@Provides
@ActivityScoped
fun provideSendRouter(appRouter: AppRouter, urlOpener: UrlOpener): SendRouter {
return DefaultSendRouter(appRouter, urlOpener)
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.impl.navigation
import androidx.fragment.app.Fragment
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.impl.presentation.SendFragment
internal class DefaultSendRouter(
private val router: AppRouter,
private val urlOpener: UrlOpener,
) : InnerSendRouter {
override fun getEntryFragment(): Fragment = SendFragment.create()
override fun openUrl(url: String) {
urlOpener.openUrl(url)
}
override fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) {
router.pop { isSuccess ->
if (isSuccess) {
router.push(
AppRoute.CurrencyDetails(
userWalletId = userWalletId,
currency = currency,
),
)
}
}
}
override fun openQrCodeScanner(network: String) {
router.push(
AppRoute.QrScanning(
source = SourceType.SEND,
networkName = network,
),
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.send.impl.navigation
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.api.navigation.SendRouter
interface InnerSendRouter : SendRouter {
/** Open website by [url] */
fun openUrl(url: String)
/** Open token details screen by [userWalletId] and [currency] */
fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency)
/** Open QR code scanner screen */
fun openQrCodeScanner(network: String)
}

View file

@ -0,0 +1,78 @@
package com.tangem.features.send.impl.presentation
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.ui.SendScreen
import com.tangem.features.send.impl.presentation.viewmodel.SendViewModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Send fragment
*/
@AndroidEntryPoint
internal class SendFragment : ComposeFragment() {
@Inject
override lateinit var uiDependencies: UiDependencies
@Inject
lateinit var router: SendRouter
@Inject
lateinit var appRouter: AppRouter
@Inject
lateinit var analyticsEventsHandler: AnalyticsEventHandler
private val viewModel by viewModels<SendViewModel>()
private val innerSendRouter: InnerSendRouter
get() = requireNotNull(router as? InnerSendRouter) {
"innerSendRouter should be instance of InnerSendRouter"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(viewModel)
val isEditingDisabled = arguments?.getString(AppRoute.Send.TRANSACTION_ID_KEY) != null
viewModel.setRouter(
innerSendRouter,
StateRouter(
appRouter = appRouter,
isEditingDisabled = isEditingDisabled,
analyticsEventsHandler = analyticsEventsHandler,
),
)
}
@Composable
override fun ScreenContent(modifier: Modifier) {
val currentState = viewModel.stateRouter.currentState.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SendScreen(uiState, currentState.value)
}
override fun onDestroy() {
lifecycle.removeObserver(viewModel)
super.onDestroy()
}
companion object {
/** Create send fragment instance */
fun create(): SendFragment = SendFragment()
}
}

View file

@ -0,0 +1,159 @@
package com.tangem.features.send.impl.presentation.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.VALIDATION
import com.tangem.core.analytics.models.AnalyticsParam.OnOffState
/**
* Send screen analytics
*/
internal sealed class SendAnalyticEvents(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Token / Send", event = event, params = params) {
/** Close button clicked */
data class CloseButtonClicked(
val source: SendScreenSource,
val isFromSummary: Boolean,
val isValid: Boolean,
) : SendAnalyticEvents(
event = "Button - Close",
params = mapOf(
SOURCE to source.name,
"FromSummary" to if (isFromSummary) "Yes" else "No",
"isValid" to if (isValid) "Yes" else "No",
),
)
// region Address
/** Recipient address screen opened */
data object AddressScreenOpened : SendAnalyticEvents(event = "Address Screen Opened")
/** Address to send entered */
data class AddressEntered(val source: EnterAddressSource, val isValid: Boolean) : SendAnalyticEvents(
event = "Address Entered",
params = mapOf(
SOURCE to source.name,
VALIDATION to if (isValid) "Success" else "Fail",
),
)
/** Qr Code button clicked */
data object QrCodeButtonClicked : SendAnalyticEvents(event = "Button - QR Code")
// endregion
// region Amount
/** Amount screen opened */
data object AmountScreenOpened : SendAnalyticEvents(event = "Amount Screen Opened")
/** Selected currency */
data class SelectedCurrency(val type: SelectedCurrencyType) : SendAnalyticEvents(
event = "Selected Currency",
params = mapOf(TYPE to type.value),
)
/** Max amount button clicked */
data object MaxAmountButtonClicked : SendAnalyticEvents(event = "Max Amount Taped")
// endregion
// region Fee
/** Fee screen opened */
data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened")
/** Selected fee (send after next screen opened) */
data class SelectedFee(val feeType: AnalyticsParam.FeeType) : SendAnalyticEvents(
event = "Fee Selected",
params = mapOf("Fee Type" to feeType.value),
)
/** Custom fee selected */
data object CustomFeeButtonClicked : SendAnalyticEvents(event = "Custom Fee Clicked")
/** Custom fee edited */
data object GasPriceInserter : SendAnalyticEvents(event = "Gas Price Inserted")
/** Subtract from amount selector switched (send after next screen opened) */
data class SubtractFromAmount(val status: Boolean) : SendAnalyticEvents(
event = "Subtract from Amount",
params = mapOf("Status" to if (status) OnOffState.On.value else OnOffState.Off.value),
)
// endregion
// region Confirmation
/** Confirmation screen opened */
data object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened")
/** Screen reopened from confirmation screen */
data class ScreenReopened(val source: SendScreenSource) : SendAnalyticEvents(
event = "Screen Reopened",
params = mapOf(SOURCE to source.name),
)
// endregion
// region Transaction Result
/** Transaction send screen opened */
data class TransactionScreenOpened(
val token: String,
val feeType: AnalyticsParam.FeeType,
) : SendAnalyticEvents(
event = "Transaction Sent Screen Opened",
params = mapOf(
TOKEN_PARAM to token,
FEE_TYPE to feeType.value,
),
)
/** Share button clicked */
data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share")
/** Expore button clicked */
data object ExploreButtonClicked : SendAnalyticEvents(event = "Button - Explore")
/** If not enough fee notification is present */
data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SendAnalyticEvents(
event = "Notice - Not Enough Fee",
params = mapOf(TOKEN_PARAM to token, BLOCKCHAIN to blockchain),
)
/** If transaction delays notification is present */
data class NoticeTransactionDelays(val token: String) : SendAnalyticEvents(
event = "Notice - Transaction Delays Are Possible",
params = mapOf(TOKEN_PARAM to token),
)
data object NoticeFeeCoverage : SendAnalyticEvents(
event = "Notice - Network Fee Coverage",
)
/** If error occurs during send transactions */
data class TransactionError(val token: String) : SendAnalyticEvents(
event = "Error - Transaction Rejected",
params = mapOf(TOKEN_PARAM to token),
)
// endregion
}
internal enum class SendScreenSource {
Address,
Amount,
Fee,
Confirm,
}
internal enum class EnterAddressSource {
QRCode,
PasteButton,
RecentAddress,
}
internal enum class SelectedCurrencyType(val value: String) {
Token("Token"),
AppCurrency("App Currency"),
}

View file

@ -0,0 +1,134 @@
package com.tangem.features.send.impl.presentation.analytics.utils
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.send.impl.presentation.analytics.SelectedCurrencyType
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.analytics.SendScreenSource
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.utils.Provider
internal class SendScreenAnalyticSender(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyProvider: Provider<CryptoCurrency>,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
fun send(prevScreen: SendUiStateType, state: SendUiState) {
when (prevScreen) {
SendUiStateType.Fee -> {
val feeState = state.getFeeState(stateRouterProvider().isEditState) ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content
feeSelectorState?.selectedFee?.let { selectedFee ->
val isCustomFeeEdited = feeState.fee?.amount?.value != feeSelectorState.fees.normal.amount.value
if (selectedFee == FeeType.Custom && isCustomFeeEdited) {
analyticsEventHandler.send(SendAnalyticEvents.GasPriceInserter)
}
sendSelectedFeeAnalytics(feeSelectorState)
}
}
SendUiStateType.Amount -> {
val amountState = state.getAmountState(stateRouterProvider().isEditState) as? AmountState.Data ?: return
val isFiatSelected = amountState.amountTextField.isFiatValue
val selectedCurrency = if (!isFiatSelected) {
SelectedCurrencyType.Token
} else {
SelectedCurrencyType.AppCurrency
}
analyticsEventHandler.send(
SendAnalyticEvents.SelectedCurrency(selectedCurrency),
)
}
else -> Unit
}
}
fun sendOnClose() {
val routerState = stateRouterProvider().currentState.value
val state = currentStateProvider()
val (source, isValid) = when (routerState.type) {
SendUiStateType.Recipient,
SendUiStateType.EditRecipient,
-> SendScreenSource.Address to (state.editRecipientState?.isPrimaryButtonEnabled ?: false)
SendUiStateType.Amount,
SendUiStateType.EditAmount,
-> SendScreenSource.Amount to (state.editAmountState?.isPrimaryButtonEnabled ?: false)
SendUiStateType.Fee,
SendUiStateType.EditFee,
-> SendScreenSource.Fee to (state.editFeeState?.isPrimaryButtonEnabled ?: false)
else -> SendScreenSource.Confirm to true
}
analyticsEventHandler.send(
SendAnalyticEvents.CloseButtonClicked(
source = source,
isFromSummary = routerState.isFromConfirmation,
isValid = isValid,
),
)
}
fun sendTransaction() {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val cryptoCurrency = cryptoCurrencyProvider()
val feeState = state.getFeeState(isEditState) ?: return
val recipientState = state.getRecipientState(isEditState) ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val feeType = getSendTransactionFeeType(feeSelectorState)
analyticsEventHandler.send(
SendAnalyticEvents.TransactionScreenOpened(
token = cryptoCurrency.symbol,
feeType = feeType,
),
)
analyticsEventHandler.send(
Basic.TransactionSent(
sentFrom = AnalyticsParam.TxSentFrom.Send(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
feeType = feeType,
),
memoType = getSendTransactionMemoType(recipientState.memoTextField),
),
)
}
private fun sendSelectedFeeAnalytics(feeSelectorState: FeeSelectorState.Content) {
val type = getSendTransactionFeeType(feeSelectorState)
analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(type))
}
private fun getSendTransactionFeeType(feeSelectorState: FeeSelectorState.Content): AnalyticsParam.FeeType =
when (feeSelectorState.fees) {
is TransactionFee.Single -> AnalyticsParam.FeeType.Fixed
is TransactionFee.Choosable -> when (feeSelectorState.selectedFee) {
FeeType.Slow -> AnalyticsParam.FeeType.Min
FeeType.Market -> AnalyticsParam.FeeType.Normal
FeeType.Fast -> AnalyticsParam.FeeType.Max
FeeType.Custom -> AnalyticsParam.FeeType.Custom
}
}
private fun getSendTransactionMemoType(
recipientMemo: SendTextField.RecipientMemo?,
): Basic.TransactionSent.MemoType {
val memo = recipientMemo?.value
return when {
memo?.isBlank() == true -> Basic.TransactionSent.MemoType.Empty
memo?.isNotBlank() == true -> Basic.TransactionSent.MemoType.Full
else -> Basic.TransactionSent.MemoType.Null
}
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.impl.presentation.domain
import androidx.compose.runtime.Immutable
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
/**
* Available wallet to send
*
* @property name wallet name
* @property userWalletId wallet id
* @property address blockchain address
*/
@Immutable
data class AvailableWallet(
val name: String,
val userWalletId: UserWalletId,
val address: String,
val cryptoCurrency: CryptoCurrency,
)

View file

@ -0,0 +1,15 @@
package com.tangem.features.send.impl.presentation.domain
import androidx.annotation.DrawableRes
import com.tangem.core.ui.extensions.TextReference
data class SendRecipientListContent(
val id: String,
val title: TextReference = TextReference.EMPTY,
val subtitle: TextReference = TextReference.EMPTY,
val timestamp: TextReference? = null,
val subtitleEndOffset: Int = 0,
@DrawableRes val subtitleIconRes: Int? = null,
val isVisible: Boolean = true,
val isLoading: Boolean = false,
)

View file

@ -0,0 +1,55 @@
package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.send.impl.R
@Immutable
internal sealed class SendAlertUM : AlertUM {
data class GenericError(
override val title: TextReference? = resourceReference(id = R.string.send_alert_transaction_failed_title),
override val onConfirmClick: (() -> Unit),
) : SendAlertUM() {
override val message: TextReference = resourceReference(R.string.common_unknown_error)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.common_support)
}
data class FeeIncreased(
override val onConfirmClick: () -> Unit,
) : SendAlertUM() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_notification_high_fee_title)
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
}
data class FeeTooLow(
override val onConfirmClick: () -> Unit,
) : SendAlertUM() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_alert_fee_too_low_text)
override val confirmButtonText: TextReference = resourceReference(R.string.common_continue)
}
data class FeeTooHigh(
val times: String,
override val onConfirmClick: () -> Unit,
) : SendAlertUM() {
override val title: TextReference? = null
override val message: TextReference =
resourceReference(id = R.string.send_alert_fee_too_high_text, wrappedList(times))
override val confirmButtonText: TextReference = resourceReference(R.string.common_continue)
}
data class FeeUnreachableError(
override val onConfirmClick: (() -> Unit),
) : SendAlertUM() {
override val title: TextReference = resourceReference(R.string.send_fee_unreachable_error_title)
override val message: TextReference = resourceReference(R.string.send_fee_unreachable_error_text)
override val confirmButtonText = resourceReference(R.string.warning_button_refresh)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class SendEvent {
data class ShowSnackBar(val text: TextReference) : SendEvent()
data class ShowAlert(val alert: AlertUM) : SendEvent()
}

View file

@ -0,0 +1,136 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.alerts.TransactionErrorAlertConverter
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import java.math.BigDecimal
/**
* Factory to produce event state for [SendUiState]
*
* @param currentStateProvider [Provider] of [SendUiState]
* @param clickIntents [SendClickIntents]
* @param feeStateFactory [FeeStateFactory]
*/
internal class SendEventStateFactory(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val clickIntents: SendClickIntents,
private val feeStateFactory: FeeStateFactory,
) {
private val transactionErrorAlertConverter by lazy(LazyThreadSafetyMode.NONE) {
TransactionErrorAlertConverter(
popBackStack = clickIntents::popBackStack,
onFailedTxEmailClick = clickIntents::onFailedTxEmailClick,
)
}
fun onConsumeEventState(): SendUiState {
return currentStateProvider().copy(event = consumedEvent())
}
fun getSendTransactionErrorState(error: SendTransactionError?, onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
val event = error?.let {
transactionErrorAlertConverter.convert(error)?.let {
triggeredEvent<SendEvent>(SendEvent.ShowAlert(it), onConsume)
}
}
return state.copy(
event = event ?: consumedEvent(),
)
}
fun getFeeUpdatedAlert(fee: TransactionFee, onConsume: () -> Unit, onFeeNotIncreased: () -> Unit): SendUiState {
val state = currentStateProvider()
val feeState = state.getFeeState(stateRouterProvider().isEditState)
val feeSelector = feeState?.feeSelectorState as? FeeSelectorState.Content ?: return state
val newFee = when (fee) {
is TransactionFee.Single -> fee.normal
is TransactionFee.Choosable -> {
when (feeSelector.selectedFee) {
FeeType.Slow -> fee.minimum
FeeType.Market -> fee.normal
FeeType.Fast -> fee.priority
FeeType.Custom -> return state
}
}
}
val newFeeValue = newFee.amount.value ?: BigDecimal.ZERO
val oldFeeValue = feeStateFactory.feeConverter.convert(feeSelector).amount.value ?: BigDecimal.ZERO
val updateFeeState = feeStateFactory.onFeeOnLoadedState(fee)
return if (newFeeValue > oldFeeValue) {
updateFeeState.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(SendAlertUM.FeeIncreased(onConsume)),
onConsume = onConsume,
),
)
} else {
onFeeNotIncreased()
updateFeeState
}
}
fun getFeeTooLowAlert(onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertUM.FeeTooLow(
onConfirmClick = clickIntents::showSend,
),
),
onConsume = onConsume,
),
)
}
fun getFeeTooHighAlert(diff: String, onConsume: () -> Unit): SendUiState {
return currentStateProvider().copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertUM.FeeTooHigh(
onConfirmClick = clickIntents::showSend,
times = diff,
),
),
onConsume = onConsume,
),
)
}
fun getGenericErrorState(error: Throwable? = null, onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertUM.GenericError(
onConfirmClick = { clickIntents.onFailedTxEmailClick(error?.localizedMessage.orEmpty()) },
),
),
onConsume = onConsume,
),
)
}
fun getFeeUnreachableErrorState(onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertUM.FeeUnreachableError(onConfirmClick = clickIntents::feeReload),
),
onConsume = onConsume,
),
)
}
}

View file

@ -0,0 +1,224 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.TransactionData
import com.tangem.common.ui.amountScreen.converters.AmountStateConverter
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.stringReference
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.impl.presentation.state.common.SendSyncEditConverter
import com.tangem.features.send.impl.presentation.state.confirm.SendConfirmStateConverter
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.SendFeeStateConverter
import com.tangem.features.send.impl.presentation.state.fee.checkFeeCoverage
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientStateConverter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class SendStateFactory(
private val clickIntents: SendClickIntents,
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val isTapHelpPreviewEnabledProvider: Provider<Boolean>,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val maxEnterAmountConverter = MaxEnterAmountConverter()
private val amountStateConverter by lazy(LazyThreadSafetyMode.NONE) {
AmountStateConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
iconStateConverter = iconStateConverter,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
maxEnterAmount = maxEnterAmountConverter.convert(cryptoCurrencyStatusProvider()),
)
}
private val recipientStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientStateConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val feeStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendFeeStateConverter(
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val confirmStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendConfirmStateConverter(
isTapHelpPreviewEnabledProvider = isTapHelpPreviewEnabledProvider,
)
}
private val sendSyncEditConverter by lazy(LazyThreadSafetyMode.NONE) {
SendSyncEditConverter(currentStateProvider = currentStateProvider)
}
// region UI states
fun getInitialState(): SendUiState = SendUiState(
clickIntents = clickIntents,
event = consumedEvent(),
isEditingDisabled = false,
isBalanceHidden = false,
cryptoCurrencyName = "",
isSubtracted = false,
amountState = AmountState.Empty(false),
editAmountState = AmountState.Empty(false),
)
fun getReadyState(): SendUiState {
val state = currentStateProvider()
val amountState = if (state.amountState is AmountState.Empty) {
amountStateConverter.convert(
AmountParameters(
title = stringReference(userWalletProvider().name),
value = "",
),
)
} else {
state.amountState
}
return state.copy(
amountState = amountState,
recipientState = state.recipientState
?: recipientStateConverter.convert(SendRecipientStateConverter.Data("", null)),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
sendState = confirmStateConverter.convert(Unit),
cryptoCurrencyName = cryptoCurrencyStatusProvider().currency.name,
)
}
fun getReadyState(amount: String, destinationAddress: String, memo: String?): SendUiState {
val state = currentStateProvider()
val amountState = if (state.amountState is AmountState.Empty) {
amountStateConverter.convert(
AmountParameters(
title = stringReference(userWalletProvider().name),
value = amount,
),
)
} else {
state.amountState
}
return state.copy(
amountState = amountState,
recipientState = state.recipientState
?: recipientStateConverter.convert(SendRecipientStateConverter.Data(destinationAddress, memo)),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
sendState = confirmStateConverter.convert(Unit),
isEditingDisabled = true,
cryptoCurrencyName = cryptoCurrencyStatusProvider().currency.name,
)
}
fun syncEditStates(isFromEdit: Boolean) = sendSyncEditConverter.convert(isFromEdit)
fun getOnHideBalanceState(isBalanceHidden: Boolean): SendUiState {
return currentStateProvider().copy(isBalanceHidden = isBalanceHidden)
}
//endregion
//region send
fun getIsAmountSubtractedState(isAmountSubtractAvailable: Boolean): SendUiState {
val state = currentStateProvider()
val balance = cryptoCurrencyStatusProvider().value.amount ?: return state
val amountState = state.getAmountState(stateRouterProvider().isEditState) as? AmountState.Data ?: return state
val feeState = state.getFeeState(stateRouterProvider().isEditState) ?: return state
val amountValue = amountState.amountTextField.cryptoAmount.value ?: return state
val feeValue = feeState.fee?.amount?.value ?: BigDecimal.ZERO
return state.copy(
isSubtracted = checkFeeCoverage(
isSubtractAvailable = isAmountSubtractAvailable,
balance = balance,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = state.sendState?.reduceAmountBy,
),
)
}
fun getSendingStateUpdate(isSending: Boolean): SendUiState {
val state = currentStateProvider()
return state.copy(
sendState = state.sendState?.copy(
isSending = isSending,
isPrimaryButtonEnabled = isPrimaryButtonEnabled(
state = state,
isSending = isSending,
notifications = state.sendState.notifications,
),
),
)
}
fun getTransactionSendState(txData: TransactionData.Uncompiled, txUrl: String): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState ?: return state
return state.copy(
sendState = sendState.copy(
transactionDate = txData.date?.timeInMillis ?: System.currentTimeMillis(),
isSuccess = true,
showTapHelp = false,
txUrl = txUrl,
notifications = persistentListOf(),
),
)
}
fun getSendNotificationState(notifications: ImmutableList<NotificationUM>): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState ?: return state
val reducedBy = sendState.reduceAmountBy.takeIf {
notifications.none {
it is NotificationUM.Error.ExistentialDeposit ||
it is NotificationUM.Error.TransactionLimitError ||
it is NotificationUM.Warning.HighFeeError
}
}
return state.copy(
sendState = sendState.copy(
isPrimaryButtonEnabled = isPrimaryButtonEnabled(
state = state,
isSending = sendState.isSending,
notifications = notifications,
),
reduceAmountBy = reducedBy,
notifications = notifications,
showTapHelp = sendState.showTapHelp && notifications.isEmpty(),
),
)
}
fun getHiddenTapHelpState(): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState ?: return state
return state.copy(
sendState = sendState.copy(showTapHelp = false),
)
}
private fun isPrimaryButtonEnabled(
state: SendUiState,
isSending: Boolean,
notifications: ImmutableList<NotificationUM>,
): Boolean {
val feeState = state.getFeeState(stateRouterProvider().isEditState) ?: return false
val hasErrorNotifications = notifications.any { it is NotificationUM.Error }
return !hasErrorNotifications && !isSending && feeState.feeSelectorState is FeeSelectorState.Content
}
//endregion
}

View file

@ -0,0 +1,151 @@
package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
/**
* Ui states of the send screen
*/
@Immutable
internal data class SendUiState(
val clickIntents: SendClickIntents,
val isEditingDisabled: Boolean,
val cryptoCurrencyName: String,
val amountState: AmountState,
val recipientState: SendStates.RecipientState? = null,
val feeState: SendStates.FeeState? = null,
val sendState: SendStates.SendState? = null,
val editAmountState: AmountState,
val editRecipientState: SendStates.RecipientState? = null,
val editFeeState: SendStates.FeeState? = null,
val isBalanceHidden: Boolean,
val isSubtracted: Boolean,
val event: StateEvent<SendEvent>,
) {
fun getAmountState(isEditState: Boolean): AmountState {
return if (isEditState) {
editAmountState
} else {
amountState
}
}
fun getRecipientState(isEditState: Boolean): SendStates.RecipientState? {
return if (isEditState) {
editRecipientState
} else {
recipientState
}
}
fun getFeeState(isEditState: Boolean): SendStates.FeeState? {
return if (isEditState) {
editFeeState
} else {
feeState
}
}
fun copyWrapped(
isEditState: Boolean,
amountState: AmountState = this.amountState,
feeState: SendStates.FeeState? = this.feeState,
recipientState: SendStates.RecipientState? = this.recipientState,
sendState: SendStates.SendState? = this.sendState,
): SendUiState = if (isEditState) {
copy(
editAmountState = amountState,
editFeeState = feeState,
editRecipientState = recipientState,
sendState = sendState,
)
} else {
copy(
amountState = amountState,
feeState = feeState,
recipientState = recipientState,
sendState = sendState,
)
}
}
@Stable
internal sealed class SendStates {
abstract val type: SendUiStateType
abstract val isPrimaryButtonEnabled: Boolean
/** Recipient state */
@Stable
data class RecipientState(
override val type: SendUiStateType = SendUiStateType.Recipient,
override val isPrimaryButtonEnabled: Boolean,
val addressTextField: SendTextField.RecipientAddress,
val memoTextField: SendTextField.RecipientMemo?,
val recent: ImmutableList<SendRecipientListContent>,
val wallets: ImmutableList<SendRecipientListContent>,
val network: String,
val isValidating: Boolean = false,
) : SendStates()
/** Fee and speed state */
@Stable
data class FeeState(
override val type: SendUiStateType = SendUiStateType.Fee,
override val isPrimaryButtonEnabled: Boolean = false,
val feeSelectorState: FeeSelectorState,
val fee: Fee?,
val rate: BigDecimal?,
val isFeeConvertibleToFiat: Boolean,
val appCurrency: AppCurrency,
val isFeeApproximate: Boolean,
val isCustomSelected: Boolean,
val notifications: ImmutableList<NotificationUM>,
val isTronToken: Boolean,
) : SendStates()
/** Send state */
@Stable
data class SendState(
override val type: SendUiStateType = SendUiStateType.Send,
override val isPrimaryButtonEnabled: Boolean = false,
val isSending: Boolean,
val isSuccess: Boolean,
val transactionDate: Long,
val txUrl: String,
val ignoreAmountReduce: Boolean,
val reduceAmountBy: BigDecimal?,
val isFromConfirmation: Boolean,
val showTapHelp: Boolean,
val notifications: ImmutableList<NotificationUM>,
) : SendStates()
}
data class SendUiCurrentScreen(
val type: SendUiStateType,
val isFromConfirmation: Boolean,
)
enum class SendUiStateType {
None,
Recipient,
Amount,
Fee,
Send,
EditAmount,
EditRecipient,
EditFee,
}

View file

@ -0,0 +1,127 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
internal class StateRouter(
private val appRouter: AppRouter,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val isEditingDisabled: Boolean,
) {
private var mutableCurrentState: MutableStateFlow<SendUiCurrentScreen> = MutableStateFlow(getInitState())
val currentState: StateFlow<SendUiCurrentScreen>
get() = mutableCurrentState
val isEditState: Boolean
get() = currentState.value.isFromConfirmation
fun clear() {
mutableCurrentState.update { getInitState() }
}
fun popBackStack() {
appRouter.pop()
}
fun onBackClick(isSuccess: Boolean = false) {
val type = currentState.value.type
when {
isSuccess -> popBackStack()
isEditingDisabled -> when (type) {
SendUiStateType.EditFee -> showSend()
else -> popBackStack()
}
else -> when (type) {
SendUiStateType.Amount -> showRecipient()
SendUiStateType.Fee -> showSend()
SendUiStateType.Send -> showAmount()
SendUiStateType.Recipient -> popBackStack()
SendUiStateType.EditAmount -> showSend()
SendUiStateType.EditRecipient -> showSend()
SendUiStateType.EditFee -> showSend()
else -> popBackStack()
}
}
}
fun onNextClick() {
when (currentState.value.type) {
SendUiStateType.Recipient -> showAmount()
SendUiStateType.Amount,
SendUiStateType.Fee,
SendUiStateType.EditAmount,
SendUiStateType.EditRecipient,
SendUiStateType.EditFee,
-> showSend()
SendUiStateType.Send -> onBackClick()
else -> popBackStack()
}
}
fun onPrevClick() {
if (isEditingDisabled) {
popBackStack()
} else {
when (currentState.value.type) {
SendUiStateType.Amount -> showRecipient()
else -> popBackStack()
}
}
}
fun showAmount(isFromConfirmation: Boolean = false) {
analyticsEventsHandler.send(SendAnalyticEvents.AmountScreenOpened)
mutableCurrentState.update {
if (isFromConfirmation) {
SendUiCurrentScreen(SendUiStateType.EditAmount, true)
} else {
SendUiCurrentScreen(SendUiStateType.Amount, false)
}
}
}
fun showRecipient(isFromConfirmation: Boolean = false) {
analyticsEventsHandler.send(SendAnalyticEvents.AddressScreenOpened)
mutableCurrentState.update {
if (isFromConfirmation) {
SendUiCurrentScreen(SendUiStateType.EditRecipient, true)
} else {
SendUiCurrentScreen(SendUiStateType.Recipient, false)
}
}
}
fun showFee(isFromConfirmation: Boolean = false) {
analyticsEventsHandler.send(SendAnalyticEvents.FeeScreenOpened)
mutableCurrentState.update {
if (isFromConfirmation) {
SendUiCurrentScreen(SendUiStateType.EditFee, true)
} else {
SendUiCurrentScreen(SendUiStateType.Fee, false)
}
}
}
fun showSend() {
analyticsEventsHandler.send(SendAnalyticEvents.ConfirmationScreenOpened)
mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Send, isFromConfirmation = false) }
}
private fun getInitState() = if (isEditingDisabled) {
SendUiCurrentScreen(
type = SendUiStateType.None,
isFromConfirmation = false,
)
} else {
SendUiCurrentScreen(
type = SendUiStateType.Recipient,
isFromConfirmation = false,
)
}
}

View file

@ -0,0 +1,89 @@
package com.tangem.features.send.impl.presentation.state.amount
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldChangeConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldMaxAmountConverter
import com.tangem.utils.Provider
import java.math.BigDecimal
/**
* Factory to produce amount state for [SendUiState]
*/
internal class AmountStateFactory(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val minimumTransactionAmountProvider: Provider<EnterAmountBoundary?>,
) {
private val amountFieldChangeConverter by lazy(LazyThreadSafetyMode.NONE) {
SendAmountFieldChangeConverter(
stateRouterProvider = stateRouterProvider,
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
minimumTransactionAmountProvider = minimumTransactionAmountProvider,
)
}
private val amountFieldMaxAmountConverter by lazy(LazyThreadSafetyMode.NONE) {
SendAmountFieldMaxAmountConverter(
stateRouterProvider = stateRouterProvider,
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
minimumTransactionAmountProvider = minimumTransactionAmountProvider,
)
}
private val amountCurrencyConverter by lazy(LazyThreadSafetyMode.NONE) {
SendAmountCurrencyConverter(
stateRouterProvider = stateRouterProvider,
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val amountPasteConverter by lazy(LazyThreadSafetyMode.NONE) {
SendAmountPastedTriggerDismissConverter(
stateRouterProvider = stateRouterProvider,
currentStateProvider = currentStateProvider,
)
}
private val amountReduceByConverter by lazy {
SendAmountReduceByConverter(
stateRouterProvider = stateRouterProvider,
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
minimumTransactionAmountProvider = minimumTransactionAmountProvider,
)
}
private val amountReduceToConverter by lazy {
SendAmountReduceToConverter(
stateRouterProvider = stateRouterProvider,
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
minimumTransactionAmountProvider = minimumTransactionAmountProvider,
)
}
fun getOnAmountValueChange(value: String) = amountFieldChangeConverter.convert(value)
fun getOnAmountReduceByState(reduceAmountBy: BigDecimal, reduceAmountByDiff: BigDecimal) =
amountReduceByConverter.convert(
AmountReduceByTransformer.ReduceByData(
reduceAmountBy = reduceAmountBy,
reduceAmountByDiff = reduceAmountByDiff,
),
)
fun getOnAmountReduceToState(reduceAmountTo: BigDecimal) = amountReduceToConverter.convert(reduceAmountTo)
fun getOnMaxAmountClick(): SendUiState {
return amountFieldMaxAmountConverter.convert(Unit)
}
fun getOnCurrencyChangedState(isFiat: Boolean) = amountCurrencyConverter.convert(isFiat)
fun getOnAmountPastedTriggerDismiss() = amountPasteConverter.convert(false)
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.send.impl.presentation.state.amount
import com.tangem.common.ui.amountScreen.converters.AmountCurrencyTransformer
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SendAmountCurrencyConverter(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Boolean, SendUiState> {
override fun convert(value: Boolean): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val amountState = state.getAmountState(isEditState) as? AmountState.Data ?: return state
return state.copyWrapped(
isEditState = isEditState,
amountState = AmountCurrencyTransformer(cryptoCurrencyStatusProvider(), value).transform(amountState),
)
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.send.impl.presentation.state.amount
import com.tangem.common.ui.amountScreen.converters.AmountPastedTriggerDismissTransformer
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SendAmountPastedTriggerDismissConverter(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
) : Converter<Boolean, SendUiState> {
override fun convert(value: Boolean): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val amountState = state.getAmountState(isEditState) as? AmountState.Data ?: return state
return state.copyWrapped(
isEditState = isEditState,
amountState = AmountPastedTriggerDismissTransformer().transform(amountState),
)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.send.impl.presentation.state.amount
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SendAmountReduceByConverter(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val minimumTransactionAmountProvider: Provider<EnterAmountBoundary?>,
) : Converter<AmountReduceByTransformer.ReduceByData, SendUiState> {
override fun convert(value: AmountReduceByTransformer.ReduceByData): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val amountState = state.getAmountState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
sendState = state.sendState?.copy(
reduceAmountBy = value.reduceAmountBy,
),
amountState = AmountReduceByTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatusProvider(),
minimumTransactionAmount = minimumTransactionAmountProvider(),
value = value,
).transform(amountState),
)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.send.impl.presentation.state.amount
import com.tangem.common.ui.amountScreen.converters.AmountReduceToTransformer
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class SendAmountReduceToConverter(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val minimumTransactionAmountProvider: Provider<EnterAmountBoundary?>,
) : Converter<BigDecimal, SendUiState> {
override fun convert(value: BigDecimal): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val amountState = state.getAmountState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
amountState = AmountReduceToTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatusProvider(),
minimumTransactionAmount = minimumTransactionAmountProvider(),
value = value,
).transform(amountState),
)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.send.impl.presentation.state.common
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SendSyncEditConverter(
private val currentStateProvider: Provider<SendUiState>,
) : Converter<Boolean, SendUiState> {
override fun convert(value: Boolean): SendUiState {
val state = currentStateProvider()
return if (value) {
state.copy(
amountState = state.editAmountState,
feeState = state.editFeeState,
recipientState = state.editRecipientState,
)
} else {
state.copy(
editAmountState = state.amountState,
editRecipientState = state.recipientState,
editFeeState = state.feeState,
)
}
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.send.impl.presentation.state.confirm
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
internal class SendConfirmStateConverter(
private val isTapHelpPreviewEnabledProvider: Provider<Boolean>,
) : Converter<Unit, SendStates.SendState> {
override fun convert(value: Unit): SendStates.SendState {
return SendStates.SendState(
isPrimaryButtonEnabled = false,
isSending = false,
isSuccess = false,
transactionDate = 0L,
txUrl = "",
ignoreAmountReduce = false,
reduceAmountBy = null,
isFromConfirmation = true,
showTapHelp = isTapHelpPreviewEnabledProvider(),
notifications = persistentListOf(),
)
}
}

View file

@ -0,0 +1,342 @@
package com.tangem.features.send.impl.presentation.state.confirm
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.minimalAmount
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addMinimumAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addRentExemptionNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.isTezos
import com.tangem.utils.Provider
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
@Suppress("LongParameterList", "LargeClass")
internal class SendNotificationFactory(
private val analyticsEventHandler: AnalyticsEventHandler,
private val validateTransactionUseCase: ValidateTransactionUseCase,
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val currentStateProvider: Provider<SendUiState>,
private val stateRouterProvider: Provider<StateRouter>,
private val isSubtractAvailableProvider: Provider<Boolean>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val clickIntents: SendClickIntents,
private val userWalletId: UserWalletId,
) {
fun create(): Flow<ImmutableList<NotificationUM>> = stateRouterProvider().currentState
.filter { it.type == SendUiStateType.Send }
.map {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val balance = cryptoCurrencyStatus.value.amount.orZero()
val sendState = state.sendState ?: return@map persistentListOf()
val feeState = state.getFeeState(isEditState) ?: return@map persistentListOf()
val amountState = state.getAmountState(isEditState) as? AmountState.Data ?: return@map persistentListOf()
val amountValue = amountState.amountTextField.cryptoAmount.value.orZero()
val feeValue = feeState.fee?.amount?.value.orZero()
val reduceAmountBy = sendState.reduceAmountBy.orZero()
val isFeeCoverage = checkFeeCoverage(
isSubtractAvailable = isSubtractAvailableProvider(),
balance = balance,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
val sendingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isSubtractAvailableProvider(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
val feeError = (feeState.feeSelectorState as? FeeSelectorState.Error)?.error
val recipientAddress = state.recipientState?.addressTextField?.value
val feeCurrencyBalanceAfterTransaction = getFeeCurrencyBalanceAfterTx(
feeCurrencyStatus = feeCryptoCurrencyStatusProvider(),
sendingCurrencyStatus = cryptoCurrencyStatus,
sendingAmount = sendingAmount,
feeValue = feeValue,
)
val currencyCheck = getCurrencyCheckUseCase(
userWalletId = userWalletId,
currencyStatus = cryptoCurrencyStatus,
amount = sendingAmount,
fee = feeValue,
recipientAddress = recipientAddress,
feeCurrencyBalanceAfterTransaction = feeCurrencyBalanceAfterTransaction,
)
buildList {
addErrorNotifications(
feeError = feeError,
sendingAmount = sendingAmount,
feeValue = feeValue,
currencyCheck = currencyCheck,
)
addWarningNotifications(
amountState = amountState,
feeState = feeState,
sendState = sendState,
sendingAmount = sendingAmount,
isFeeCoverage = isFeeCoverage,
currencyCheck = currencyCheck,
)
}.toImmutableList()
}
fun dismissNotificationState(clazz: Class<out NotificationUM>, isIgnored: Boolean = false): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState ?: return state
val notificationsToRemove = sendState.notifications.filterIsInstance(clazz)
val updatedNotifications = sendState.notifications.toMutableList()
updatedNotifications.removeAll(notificationsToRemove)
return state.copy(
sendState = sendState.copy(
ignoreAmountReduce = isIgnored,
reduceAmountBy = if (isIgnored) null else sendState.reduceAmountBy,
notifications = updatedNotifications.toImmutableList(),
),
)
}
private fun getFeeCurrencyBalanceAfterTx(
feeCurrencyStatus: CryptoCurrencyStatus?,
sendingCurrencyStatus: CryptoCurrencyStatus,
sendingAmount: BigDecimal,
feeValue: BigDecimal,
): BigDecimal? {
val sendingCurrencyBalance = sendingCurrencyStatus.value as? CryptoCurrencyStatus.Loaded
val feeCurrencyBalance = feeCurrencyStatus?.value as? CryptoCurrencyStatus.Loaded
if (feeCurrencyStatus?.value !is CryptoCurrencyStatus.Loaded) return null
return when {
feeCurrencyStatus == sendingCurrencyStatus -> sendingCurrencyBalance?.let {
it.amount - sendingAmount - feeValue
}
else -> feeCurrencyBalance?.let { it.amount - feeValue }
}
}
private suspend fun MutableList<NotificationUM>.addErrorNotifications(
feeError: GetFeeError?,
sendingAmount: BigDecimal,
feeValue: BigDecimal,
currencyCheck: CryptoCurrencyCheck,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val currency = cryptoCurrencyStatusProvider().currency
val currencyWarning = getBalanceNotEnoughForFeeWarningUseCase(
fee = feeValue,
userWalletId = userWalletId,
tokenStatus = cryptoCurrencyStatus,
coinStatus = feeCryptoCurrencyStatusProvider() ?: cryptoCurrencyStatus,
).getOrNull()
addFeeUnreachableNotification(
tokenStatus = cryptoCurrencyStatus,
coinStatus = feeCryptoCurrencyStatusProvider() ?: cryptoCurrencyStatus,
feeError = feeError,
onReload = clickIntents::feeReload,
onClick = clickIntents::onTokenDetailsClick,
)
addExceedBalanceNotification(
feeAmount = feeValue,
sendingAmount = sendingAmount,
isSubtractionAvailable = isSubtractAvailableProvider(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
)
addExceedsBalanceNotification(
cryptoCurrencyWarning = currencyWarning,
cryptoCurrencyStatus = cryptoCurrencyStatus,
shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(currency.network.backendId),
onClick = clickIntents::onTokenDetailsClick,
onAnalyticsEvent = {
analyticsEventHandler.send(
SendAnalyticEvents.NoticeNotEnoughFee(
token = cryptoCurrencyStatus.currency.symbol,
blockchain = cryptoCurrencyStatus.currency.network.name,
),
)
},
)
if (!BlockchainUtils.isCardano(currency.network.id.value)) {
addDustWarningNotification(
dustValue = currencyCheck.dustValue,
feeValue = feeValue,
sendingAmount = sendingAmount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCurrencyStatus = feeCryptoCurrencyStatusProvider(),
)
}
addTransactionLimitErrorNotification(
currencyCheck = currencyCheck,
sendingAmount = sendingAmount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCurrencyStatus = feeCryptoCurrencyStatusProvider(),
feeValue = feeValue,
onReduceClick = clickIntents::onAmountReduceToClick,
)
addReserveAmountErrorNotification(
reserveAmount = currencyCheck.reserveAmount,
sendingAmount = sendingAmount,
cryptoCurrency = currency,
isAccountFunded = currencyCheck.isAccountFunded,
)
addMinimumAmountErrorNotification(
minimumSendAmount = currencyCheck.minimumSendAmount,
sendingAmount = sendingAmount,
cryptoCurrency = currency,
)
}
private suspend fun MutableList<NotificationUM>.addWarningNotifications(
amountState: AmountState.Data,
feeState: SendStates.FeeState,
sendState: SendStates.SendState,
sendingAmount: BigDecimal,
isFeeCoverage: Boolean,
currencyCheck: CryptoCurrencyCheck,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val currency = cryptoCurrencyStatus.currency
val amountValue = amountState.amountTextField.cryptoAmount.value
val validationError = amountValue?.let {
validateTransactionUseCase(
userWalletId = userWalletId,
amount = amountValue.convertToSdkAmount(cryptoCurrencyStatus.currency),
fee = feeState.fee,
memo = null,
destination = "",
network = cryptoCurrencyStatus.currency.network,
).leftOrNull()
}
addRentExemptionNotification(
rentWarning = currencyCheck.rentWarning,
)
addExistentialWarningNotification(
existentialDeposit = currencyCheck.existentialDeposit,
feeAmount = feeState.fee?.amount?.value.orZero(),
sendingAmount = sendingAmount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
onReduceClick = clickIntents::onAmountReduceByClick,
)
addFeeCoverageNotification(
isFeeCoverage = isFeeCoverage,
amountField = amountState.amountTextField,
sendingValue = sendingAmount,
appCurrency = appCurrencyProvider(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
)
addValidateTransactionNotifications(
dustValue = currencyCheck.dustValue.orZero(),
minAdaValue = (feeState.fee as? Fee.CardanoToken)?.minAdaValue,
validationError = validationError,
cryptoCurrency = currency,
onReduceClick = clickIntents::onAmountReduceToClick,
)
addHighFeeWarningNotification(
amountState.amountTextField.cryptoAmount.value.orZero(),
sendState.ignoreAmountReduce,
)
addTooHighNotification(feeState.feeSelectorState)
addTooLowNotification(feeState)
}
private fun MutableList<NotificationUM>.addHighFeeWarningNotification(
sendAmount: BigDecimal,
ignoreAmountReduce: Boolean,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val isTezos = isTezos(cryptoCurrencyStatus.currency.network.id.value)
val threshold = Blockchain.Tezos.minimalAmount()
val isTotalBalance = sendAmount >= balance && balance > threshold
if (!ignoreAmountReduce && isTotalBalance && isTezos) {
add(
NotificationUM.Warning.HighFeeError(
currencyName = cryptoCurrencyStatus.currency.name,
amount = threshold.toPlainString(),
onConfirmClick = {
clickIntents.onAmountReduceByClick(
reduceAmountBy = threshold,
reduceAmountByDiff = threshold,
notification = NotificationUM.Warning.HighFeeError::class.java,
)
},
onCloseClick = {
clickIntents.onNotificationCancel(NotificationUM.Warning.HighFeeError::class.java)
},
),
)
}
}
private fun MutableList<NotificationUM>.addTooLowNotification(feeState: SendStates.FeeState) {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return
val minimumValue = multipleFees.minimum.amount.value ?: return
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
if (feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue) {
add(NotificationUM.Warning.FeeTooLow)
analyticsEventHandler.send(
SendAnalyticEvents.NoticeTransactionDelays(
cryptoCurrencyStatusProvider().currency.symbol,
),
)
}
}
private fun MutableList<NotificationUM>.addTooHighNotification(feeSelectorState: FeeSelectorState) {
if (feeSelectorState !is FeeSelectorState.Content) return
checkIfFeeTooHigh(feeSelectorState) { diff ->
add(NotificationUM.Warning.TooHigh(diff))
}
}
}

View file

@ -0,0 +1,89 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import java.math.BigDecimal
import java.math.RoundingMode
/**
* 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
}
}
/**
* 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
}
/**
* Check if custom fee is too low
*/
internal fun checkIfFeeTooLow(feeSelectorState: FeeSelectorState.Content): Boolean {
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return false
val minimumValue = multipleFees.minimum.amount.value ?: return false
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return false
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
return feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue
}
/**
* Check if custom fee is too high
*/
internal fun checkIfFeeTooHigh(feeSelectorState: FeeSelectorState.Content, onShow: (String) -> Unit): Boolean {
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return false
val highValue = multipleFees.priority.amount.value ?: return false
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return false
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = if (highValue > BigDecimal.ZERO) {
customValue / highValue
} else {
BigDecimal.ZERO
}
val isShow = feeSelectorState.selectedFee == FeeType.Custom && diff > FEE_MAX_DIFF
if (isShow) onShow(diff.parseBigDecimal(ZERO_DECIMALS, RoundingMode.HALF_UP))
return isShow
}
/**
* Checks if fee exceeds fee paid currency balance
*/
fun checkExceedBalance(feeBalance: BigDecimal?, feeAmount: BigDecimal?): Boolean {
return feeAmount == null || feeBalance == null || feeAmount.isZero() || feeAmount > feeBalance
}
private val FEE_MAX_DIFF = BigDecimal("5")
private const val ZERO_DECIMALS = 0

View file

@ -0,0 +1,89 @@
package com.tangem.features.send.impl.presentation.state.fee
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.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.custom.BitcoinCustomFeeConverter
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter
import com.tangem.features.send.impl.presentation.state.fee.custom.KaspaCustomFeeConverter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class FeeConverter(
private val clickIntents: SendClickIntents,
private val stateRouterProvider: Provider<StateRouter>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : Converter<FeeSelectorState.Content, Fee> {
private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
EthereumCustomFeeConverter(
clickIntents = clickIntents,
stateRouterProvider = stateRouterProvider,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
BitcoinCustomFeeConverter(
clickIntents = clickIntents,
stateRouterProvider = stateRouterProvider,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
private val kaspaCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
KaspaCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
override fun convert(value: FeeSelectorState.Content): Fee {
return when (val fees = value.fees) {
is TransactionFee.Choosable -> {
when (value.selectedFee) {
FeeType.Slow -> fees.minimum
FeeType.Market -> fees.normal
FeeType.Fast -> fees.priority
FeeType.Custom -> convertCustom(value, fees)
}
}
is TransactionFee.Single ->
when (value.selectedFee) {
FeeType.Market -> fees.normal
FeeType.Custom -> convertCustom(value, fees)
else -> fees.normal
}
}
}
private fun convertCustom(feeSelectorState: FeeSelectorState.Content, fees: TransactionFee): Fee {
val customValues = feeSelectorState.customValues
val normalFee = fees.normal
return if (customValues.isEmpty()) {
normalFee
} else {
when (normalFee) {
is Fee.Ethereum -> ethereumCustomFeeConverter.convertBack(normalFee = normalFee, value = customValues)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.convertBack(normalFee = normalFee, value = customValues)
is Fee.Kaspa -> kaspaCustomFeeConverter.convertBack(normalFee = normalFee, value = customValues)
else -> {
val customFee = customValues.firstOrNull()
Fee.Common(
normalFee.amount.copy(
value = customFee?.value?.parseToBigDecimal(customFee.decimals),
),
)
}
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@Suppress("LongParameterList")
internal class FeeNotificationFactory(
private val currentStateProvider: Provider<SendUiState>,
private val stateRouterProvider: Provider<StateRouter>,
private val clickIntents: SendClickIntents,
) {
fun create() = stateRouterProvider().currentState
.filter { it.type == SendUiStateType.Fee || it.type == SendUiStateType.EditFee }
.map {
val state = currentStateProvider()
val feeState = state.getFeeState(stateRouterProvider().isEditState) ?: return@map persistentListOf()
buildList {
addFeeUnreachableNotification(
feeError = (feeState.feeSelectorState as? FeeSelectorState.Error)?.error,
tokenName = state.cryptoCurrencyName,
onReload = clickIntents::feeReload,
)
}.toImmutableList()
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.send.impl.presentation.state.fee
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
internal sealed class FeeSelectorState {
data class Content(
val fees: TransactionFee,
val selectedFee: FeeType = FeeType.Market,
val customValues: ImmutableList<SendTextField.CustomFee> = persistentListOf(),
) : FeeSelectorState()
data object Loading : FeeSelectorState()
data class Error(
val error: GetFeeError?,
) : FeeSelectorState()
}
enum class FeeType {
Slow,
Market,
Fast,
Custom,
}

View file

@ -0,0 +1,236 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.extensions.isZero
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import com.tangem.blockchain.common.AmountType as SdkAmountType
/**
* Factory to produce fee state for [SendUiState]
*/
internal class FeeStateFactory(
private val clickIntents: SendClickIntents,
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
) {
private val customFeeFieldConverter by lazy(LazyThreadSafetyMode.NONE) {
SendFeeCustomFieldConverter(
clickIntents = clickIntents,
stateRouterProvider = stateRouterProvider,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
val feeConverter by lazy(LazyThreadSafetyMode.NONE) {
FeeConverter(
clickIntents = clickIntents,
stateRouterProvider = stateRouterProvider,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
fun onFeeOnLoadingState(): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val feeState = state.getFeeState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
sendState = state.sendState?.copy(
isPrimaryButtonEnabled = false,
),
feeState = feeState.copy(
feeSelectorState = if (feeState.feeSelectorState is FeeSelectorState.Content) {
feeState.feeSelectorState
} else {
FeeSelectorState.Loading
},
isPrimaryButtonEnabled = false,
),
)
}
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val feeState = state.getFeeState(isEditState) ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content
val isCustomWasSelected = if (feeState.isCustomSelected) {
feeSelectorState?.customValues ?: persistentListOf()
} else {
customFeeFieldConverter.convert(fees.normal)
}
val updatedFeeSelectorState = feeSelectorState?.copy(
fees = fees,
customValues = isCustomWasSelected,
) ?: FeeSelectorState.Content(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = feeConverter.convert(updatedFeeSelectorState)
return state.copyWrapped(
isEditState = isEditState,
sendState = state.sendState?.copy(
isPrimaryButtonEnabled = true,
),
feeState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
fee = fee,
isFeeApproximate = isFeeApproximate(state.amountState),
),
)
}
fun onFeeOnErrorState(feeError: GetFeeError?): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
return state.copyWrapped(
isEditState = isEditState,
feeState = state.getFeeState(isEditState)?.copy(
feeSelectorState = FeeSelectorState.Error(feeError),
),
sendState = state.sendState?.copy(
isPrimaryButtonEnabled = false,
),
)
}
fun onFeeSelectedState(feeType: FeeType): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val feeState = state.getFeeState(isEditState) ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType)
val fee = feeConverter.convert(updatedFeeSelectorState)
val isCustomFeeWasSelected = feeState.isCustomSelected || updatedFeeSelectorState.selectedFee == FeeType.Custom
return state.copyWrapped(
isEditState = isEditState,
feeState = feeState.copy(
fee = fee,
isCustomSelected = isCustomFeeWasSelected,
feeSelectorState = updatedFeeSelectorState,
),
)
}
fun onCustomFeeValueChange(index: Int, value: String): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val feeState = state.getFeeState(isEditState) ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = customFeeFieldConverter.onValueChange(feeSelectorState, index, value)
val fee = feeConverter.convert(updatedFeeSelectorState)
return state.copyWrapped(
isEditState = isEditState,
feeState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
fee = fee,
),
)
}
fun getFeeNotificationState(notifications: ImmutableList<NotificationUM>): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val feeState = state.getFeeState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
feeState = feeState.copy(
notifications = notifications,
isPrimaryButtonEnabled = isPrimaryButtonEnabled(feeState, notifications),
),
)
}
fun tryAutoFixCustomFeeValue(): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val feeState = state.getFeeState(isEditState) ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
return when (feeSelectorState.selectedFee) {
FeeType.Slow,
FeeType.Market,
FeeType.Fast,
-> state
FeeType.Custom -> {
val updatedFeeSelectorState = customFeeFieldConverter.tryAutoFixValue(feeSelectorState)
val fee = feeConverter.convert(updatedFeeSelectorState)
return state.copyWrapped(
isEditState = isEditState,
feeState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
fee = fee,
),
)
}
}
}
private fun isPrimaryButtonEnabled(
feeState: SendStates.FeeState,
notifications: ImmutableList<NotificationUM>,
): Boolean {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return false
val customValue = feeSelectorState.customValues.firstOrNull()
val isNotCustom = feeSelectorState.selectedFee != 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)
}
private fun isFeeApproximate(state: AmountState): Boolean {
val cryptoCurrencyStatus = feeCryptoCurrencyStatusProvider() ?: return false
val amount = (state as? AmountState.Data)?.amountTextField?.cryptoAmount ?: return false
return isFeeApproximateUseCase(
networkId = cryptoCurrencyStatus.currency.network.id,
amountType = amount.type.toSdkAmountType(),
)
}
private fun AmountType.toSdkAmountType(): SdkAmountType {
return when (this) {
AmountType.CoinType -> SdkAmountType.Coin
is AmountType.FiatType -> error("unsupported type FiatType")
AmountType.ReserveType -> SdkAmountType.Reserve
is AmountType.TokenType -> SdkAmountType.Token(
Token(
name = this.token.name,
symbol = this.token.symbol,
contractAddress = this.token.contractAddress,
decimals = this.token.decimals,
id = this.token.id.rawCurrencyId?.value,
),
)
}
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.features.send.impl.presentation.state.fee
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.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.custom.BitcoinCustomFeeConverter
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter
import com.tangem.features.send.impl.presentation.state.fee.custom.KaspaCustomFeeConverter
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal class SendFeeCustomFieldConverter(
private val clickIntents: SendClickIntents,
private val stateRouterProvider: Provider<StateRouter>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : Converter<Fee, ImmutableList<SendTextField.CustomFee>> {
private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
EthereumCustomFeeConverter(
clickIntents = clickIntents,
stateRouterProvider = stateRouterProvider,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
BitcoinCustomFeeConverter(
clickIntents = clickIntents,
stateRouterProvider = stateRouterProvider,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
private val kaspaCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
KaspaCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
override fun convert(value: Fee): ImmutableList<SendTextField.CustomFee> {
return when (value) {
is Fee.Ethereum -> ethereumCustomFeeConverter.convert(value)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.convert(value)
is Fee.Kaspa -> kaspaCustomFeeConverter.convert(value)
else -> persistentListOf()
}
}
fun onValueChange(feeSelectorState: FeeSelectorState.Content, index: Int, value: String) = feeSelectorState.copy(
customValues = 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: FeeSelectorState.Content) = feeSelectorState.copy(
customValues = when (feeSelectorState.fees) {
is TransactionFee.Choosable -> feeSelectorState.fees.minimum
is TransactionFee.Single -> feeSelectorState.fees.normal
}.let {
when (it) {
is Fee.Kaspa -> kaspaCustomFeeConverter.tryAutoFixValue(
minimumFee = it,
customValues = feeSelectorState.customValues,
)
else -> feeSelectorState.customValues
}
},
)
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.send.impl.presentation.state.fee
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.impl.presentation.state.SendStates
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
internal class SendFeeStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Unit, SendStates.FeeState> {
override fun convert(value: Unit): SendStates.FeeState {
return SendStates.FeeState(
feeSelectorState = FeeSelectorState.Loading,
fee = null,
notifications = persistentListOf(),
rate = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
appCurrency = appCurrencyProvider(),
isFeeApproximate = false,
isCustomSelected = false,
isFeeConvertibleToFiat = cryptoCurrencyStatusProvider().currency.network.hasFiatFeeRate,
isTronToken = cryptoCurrencyStatusProvider().currency is CryptoCurrency.Token &&
isTron(cryptoCurrencyStatusProvider().currency.network.id.value),
)
}
}

View file

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

View file

@ -0,0 +1,144 @@
package com.tangem.features.send.impl.presentation.state.fee.custom
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.impl.R
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.checkExceedBalance
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.lib.crypto.BlockchainUtils.isUseBitcoinFeeConverter
import com.tangem.utils.Provider
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: SendClickIntents,
private val stateRouterProvider: Provider<StateRouter>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : CustomFeeConverter<Fee.Bitcoin> {
override fun convert(value: Fee.Bitcoin): ImmutableList<SendTextField.CustomFee> {
val feeValue = value.amount.value
val feeCurrency = feeCryptoCurrencyStatusProvider()?.value
val network = feeCryptoCurrencyStatusProvider()?.currency?.network?.id?.value
return if (network != null && isUseBitcoinFeeConverter(network)) {
persistentListOf(
SendTextField.CustomFee(
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.Next,
keyboardType = KeyboardType.Number,
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_bitcoin_custom_fee_footer),
label = getFiatReference(
rate = feeCurrency?.fiatRate,
value = feeValue,
appCurrency = appCurrencyProvider(),
),
keyboardActions = KeyboardActions(),
isReadonly = true,
),
SendTextField.CustomFee(
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 = feeCurrency?.amount,
feeAmount = feeValue,
)
) {
ImeAction.None
} else {
ImeAction.Done
},
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(
onDone = { clickIntents.onNextClick(stateRouterProvider().isEditState) },
),
),
)
} else {
persistentListOf()
}
}
override fun convertBack(normalFee: Fee.Bitcoin, value: ImmutableList<SendTextField.CustomFee>): 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<SendTextField.CustomFee>,
index: Int,
value: String,
txSize: BigDecimal,
): ImmutableList<SendTextField.CustomFee> {
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 = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
value = newFeeAmount,
appCurrency = appCurrencyProvider(),
),
),
)
set(index, this[index].copy(value = value))
}
}.toImmutableList()
}
private fun toSatoshiPerByte(amount: BigDecimal?, decimals: Int, txSize: BigDecimal): BigDecimal? {
val newFeeAmount = amount?.movePointRight(decimals)
return newFeeAmount?.divide(
txSize,
SATOSHI_DECIMALS,
RoundingMode.HALF_UP,
)?.setScale(SATOSHI_DECIMALS, RoundingMode.HALF_UP)
}
private companion object {
private const val FEE_AMOUNT_INDEX = 0
private const val FEE_SATOSHI_INDEX = 1
private const val SATOSHI_DECIMALS = 0
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.send.impl.presentation.state.fee.custom
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
internal interface CustomFeeConverter<T : Fee> : Converter<T, ImmutableList<SendTextField.CustomFee>> {
fun convertBack(normalFee: T, value: ImmutableList<SendTextField.CustomFee>): T
}

View file

@ -0,0 +1,135 @@
package com.tangem.features.send.impl.presentation.state.fee.custom
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.impl.R
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.checkExceedBalance
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
internal class EthereumCustomFeeConverter(
private val clickIntents: SendClickIntents,
private val stateRouterProvider: Provider<StateRouter>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : BaseEthereumCustomFeeConverter<Fee.Ethereum> {
private val feeCurrency: CryptoCurrencyStatus.Value?
get() = feeCryptoCurrencyStatusProvider()?.value
private val legacyFeeConverter = EthereumLegacyCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
private val eipFeeConverter = EthereumEIPCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
override fun convert(value: Fee.Ethereum): ImmutableList<SendTextField.CustomFee> {
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<SendTextField.CustomFee>): 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<SendTextField.CustomFee>,
index: Int,
value: String,
): ImmutableList<SendTextField.CustomFee> {
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): SendTextField.CustomFee {
val feeValue = value.amount.value
return SendTextField.CustomFee(
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 = feeCurrency?.fiatRate,
value = feeValue,
appCurrency = appCurrencyProvider(),
),
keyboardActions = KeyboardActions(),
)
}
private fun convertGasLimitValue(value: Fee.Ethereum): SendTextField.CustomFee {
val isExceedBalance = checkExceedBalance(feeBalance = feeCurrency?.amount, feeAmount = value.amount.value)
return SendTextField.CustomFee(
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(stateRouterProvider().isEditState) },
),
)
}
companion object {
const val ETHEREUM_GAS_UNIT = "GWEI"
const val GIGA_DECIMALS = 9
const val GAS_DECIMALS = 0
const val FEE_AMOUNT = 0
}
}
internal fun MutableList<SendTextField.CustomFee>.setEmpty(index: Int) {
set(index, this[index].copy(value = ""))
}

View file

@ -0,0 +1,202 @@
package com.tangem.features.send.impl.presentation.state.fee.custom
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.impl.R
import com.tangem.features.send.impl.presentation.state.fee.checkExceedBalance
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter.Companion.ETHEREUM_GAS_UNIT
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter.Companion.FEE_AMOUNT
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter.Companion.GAS_DECIMALS
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter.Companion.GIGA_DECIMALS
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
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: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : BaseEthereumCustomFeeConverter<Fee.Ethereum.EIP1559> {
override fun convert(value: Fee.Ethereum.EIP1559): ImmutableList<SendTextField.CustomFee> {
return persistentListOf(
SendTextField.CustomFee(
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(),
),
SendTextField.CustomFee(
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<SendTextField.CustomFee>,
): 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<SendTextField.CustomFee>,
index: Int,
value: String,
): ImmutableList<SendTextField.CustomFee> {
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<SendTextField.CustomFee>.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 = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
value = newFeeAmountDecimal,
appCurrency = appCurrencyProvider(),
),
),
)
}
}
private fun MutableList<SendTextField.CustomFee>.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 = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
value = newFeeAmount,
appCurrency = appCurrencyProvider(),
),
),
)
set(index, this[index].copy(value = value))
}
}
private fun MutableList<SendTextField.CustomFee>.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 = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
value = newFeeAmount,
appCurrency = appCurrencyProvider(),
),
),
)
val isNotExceedBalance = checkExceedBalance(
feeBalance = feeCryptoCurrencyStatusProvider()?.value?.amount,
feeAmount = newFeeAmount,
)
set(
index = index,
element = this[index].copy(
value = value,
keyboardOptions = KeyboardOptions(
imeAction = if (!isNotExceedBalance) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
),
)
}
}
private companion object {
const val MAX_FEE = 1
const val PRIORITY_FEE = 2
const val GAS_LIMIT = 3
}
}

View file

@ -0,0 +1,179 @@
package com.tangem.features.send.impl.presentation.state.fee.custom
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.impl.R
import com.tangem.features.send.impl.presentation.state.fee.checkExceedBalance
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter.Companion.ETHEREUM_GAS_UNIT
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter.Companion.FEE_AMOUNT
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter.Companion.GAS_DECIMALS
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter.Companion.GIGA_DECIMALS
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
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: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : BaseEthereumCustomFeeConverter<Fee.Ethereum.Legacy> {
override fun convert(value: Fee.Ethereum.Legacy): ImmutableList<SendTextField.CustomFee> {
return persistentListOf(
SendTextField.CustomFee(
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<SendTextField.CustomFee>,
): 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<SendTextField.CustomFee>,
index: Int,
value: String,
): ImmutableList<SendTextField.CustomFee> {
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<SendTextField.CustomFee>.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 = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
value = newFeeAmountDecimal,
appCurrency = appCurrencyProvider(),
),
),
)
}
}
private fun MutableList<SendTextField.CustomFee>.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 = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
value = newFeeAmount,
appCurrency = appCurrencyProvider(),
),
),
)
set(index, this[index].copy(value = value))
}
}
private fun MutableList<SendTextField.CustomFee>.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 = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
value = newFeeAmount,
appCurrency = appCurrencyProvider(),
),
),
)
val isNotExceedBalance = checkExceedBalance(
feeBalance = feeCryptoCurrencyStatusProvider()?.value?.amount,
feeAmount = newFeeAmount,
)
set(
index = index,
element = this[index].copy(
value = value,
keyboardOptions = KeyboardOptions(
imeAction = if (!isNotExceedBalance) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
),
)
}
}
private companion object {
const val GAS_PRICE = 1
const val GAS_LIMIT = 2
}
}

View file

@ -0,0 +1,135 @@
package com.tangem.features.send.impl.presentation.state.fee.custom
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.impl.R
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
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: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : CustomFeeConverter<Fee.Kaspa> {
override fun convert(value: Fee.Kaspa): ImmutableList<SendTextField.CustomFee> {
val feeValue = value.amount.value
val feeCurrency = feeCryptoCurrencyStatusProvider()?.value
val network = feeCryptoCurrencyStatusProvider()?.currency?.network?.id?.value
return if (network != null) {
persistentListOf(
SendTextField.CustomFee(
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.Next,
keyboardType = KeyboardType.Number,
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_custom_amount_fee_footer),
label = getFiatReference(
rate = feeCurrency?.fiatRate,
value = feeValue,
appCurrency = appCurrencyProvider(),
),
keyboardActions = KeyboardActions(),
),
)
} else {
persistentListOf()
}
}
override fun convertBack(normalFee: Fee.Kaspa, value: ImmutableList<SendTextField.CustomFee>): 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<SendTextField.CustomFee>,
index: Int,
value: String,
): ImmutableList<SendTextField.CustomFee> {
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 = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
value = valueDecimal,
appCurrency = appCurrencyProvider(),
),
),
)
}
}
}.toImmutableList()
}
fun tryAutoFixValue(
minimumFee: Fee.Kaspa,
customValues: ImmutableList<SendTextField.CustomFee>,
): ImmutableList<SendTextField.CustomFee> {
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 = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
value = valueDecimal,
appCurrency = appCurrencyProvider(),
),
),
)
}
}
}
}.toImmutableList()
}
private companion object {
private const val FEE_AMOUNT_INDEX = 0
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.send.impl.presentation.state.fields
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SendAmountFieldChangeConverter(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val minimumTransactionAmountProvider: Provider<EnterAmountBoundary?>,
) : Converter<String, SendUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
override fun convert(value: String): SendUiState {
val state = currentStateProvider()
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val isEditState = stateRouterProvider().isEditState
val amountState = state.getAmountState(isEditState)
val maxEnterAmount = maxEnterAmountConverter.convert(cryptoCurrencyStatus)
val minimumTransactionAmount = minimumTransactionAmountProvider()
return state.copyWrapped(
isEditState = isEditState,
sendState = state.sendState?.copy(reduceAmountBy = null),
amountState = AmountFieldChangeTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxEnterAmount = maxEnterAmount,
minimumTransactionAmount = minimumTransactionAmount,
value = value,
).transform(amountState),
)
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.impl.presentation.state.fields
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldSetMaxAmountTransformer
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
internal class SendAmountFieldMaxAmountConverter(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val minimumTransactionAmountProvider: Provider<EnterAmountBoundary?>,
) : Converter<Unit, SendUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
override fun convert(value: Unit): SendUiState {
val state = currentStateProvider()
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val isEditState = stateRouterProvider().isEditState
val amountState = state.getAmountState(isEditState) ?: return state
val decimalCryptoValue = cryptoCurrencyStatus.value.amount
if (decimalCryptoValue.isNullOrZero()) return state
val maxEnterAmount = maxEnterAmountConverter.convert(cryptoCurrencyStatus)
val minimumTransactionAmount = minimumTransactionAmountProvider()
return state.copyWrapped(
isEditState = isEditState,
sendState = state.sendState?.copy(reduceAmountBy = null),
amountState = AmountFieldSetMaxAmountTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxAmount = maxEnterAmount,
minAmount = minimumTransactionAmount,
).transform(amountState),
)
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.features.send.impl.presentation.state.fields
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 sealed class SendTextField {
/** Current value */
abstract val value: String
/** Lambda be invoked when value is been changed */
abstract val onValueChange: (String) -> Unit
/** Keyboard options */
abstract val keyboardOptions: KeyboardOptions
data class RecipientAddress(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
val placeholder: TextReference,
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val isValuePasted: Boolean,
) : SendTextField()
data class RecipientMemo(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
val placeholder: TextReference,
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val disabledText: TextReference,
val isEnabled: Boolean,
val isValuePasted: Boolean,
) : SendTextField()
data class CustomFee(
override val value: String,
override val onValueChange: (String) -> Unit,
override 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,
) : SendTextField()
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.send.impl.presentation.state.previewdata
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import kotlinx.collections.immutable.persistentListOf
internal object ConfirmStatePreviewData {
val sendState = SendStates.SendState(
type = SendUiStateType.Send,
isSending = false,
isSuccess = false,
transactionDate = 0L,
txUrl = "",
ignoreAmountReduce = false,
reduceAmountBy = null,
isFromConfirmation = false,
showTapHelp = true,
notifications = persistentListOf(),
)
val sendDoneState = SendStates.SendState(
type = SendUiStateType.Send,
isSending = false,
isSuccess = true,
transactionDate = 1695199500000L,
txUrl = "url",
ignoreAmountReduce = false,
reduceAmountBy = null,
isFromConfirmation = false,
showTapHelp = false,
notifications = persistentListOf(),
)
}

View file

@ -0,0 +1,108 @@
package com.tangem.features.send.impl.presentation.state.previewdata
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType.Coin
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
internal object FeeStatePreviewData {
private val fee = Fee.Common(
amount = Amount(
currencySymbol = "MATIC",
value = BigDecimal(0.159806),
decimals = 18,
type = Coin,
),
)
private val singleFee = TransactionFee.Single(normal = fee)
private val multipleFees = TransactionFee.Choosable(
normal = fee,
minimum = fee,
priority = fee.copy(fee.amount.copy(value = BigDecimal(0.159824))),
)
private val customValue = SendTextField.CustomFee(
value = "0.159834",
onValueChange = {},
keyboardOptions = KeyboardOptions.Default,
keyboardActions = KeyboardActions.Default,
symbol = "MATIC",
decimals = 18,
title = stringReference("Fee up to"),
footer = stringReference("Maximum commission amount"),
label = stringReference("0.41 \$"),
isReadonly = false,
)
private val customValues = persistentListOf(
customValue,
customValue.copy(
value = "400",
symbol = "GWEI",
title = stringReference("Gas price"),
footer = stringReference("Gas Price impacts transaction speed; too low, it may not process"),
label = null,
),
customValue.copy(
value = "31400",
symbol = "",
title = stringReference("Gas limit"),
footer = stringReference("Gas Limit is auto-calculated; raise it during network congestion"),
label = null,
),
)
private val feeSelector = FeeSelectorState.Content(
fees = singleFee,
selectedFee = FeeType.Market,
customValues = persistentListOf(),
)
val feeState = SendStates.FeeState(
type = SendUiStateType.Fee,
isPrimaryButtonEnabled = false,
feeSelectorState = feeSelector,
fee = fee,
rate = BigDecimal.ONE,
appCurrency = AppCurrency.Default,
isFeeApproximate = false,
notifications = persistentListOf(),
isCustomSelected = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
)
val feeChoosableState = feeState.copy(
feeSelectorState = feeSelector.copy(
fees = multipleFees,
customValues = customValues,
),
)
val feeCustomState = feeState.copy(
feeSelectorState = feeSelector.copy(
fees = multipleFees,
customValues = customValues,
selectedFee = FeeType.Custom,
),
isCustomSelected = true,
)
val errorFeeState = feeState.copy(
feeSelectorState = FeeSelectorState.Error(null),
)
}

View file

@ -0,0 +1,85 @@
package com.tangem.features.send.impl.presentation.state.previewdata
import androidx.compose.foundation.text.KeyboardOptions
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import kotlinx.collections.immutable.persistentListOf
internal object RecipientStatePreviewData {
private val defaultRecentItem = SendRecipientListContent(
id = "sanctus",
title = stringReference("0x391316a070212312312378E88CAc8A0C250"),
subtitleEndOffset = 0,
subtitleIconRes = R.drawable.ic_arrow_down_24,
isVisible = true,
isLoading = false,
)
val recipientState = SendStates.RecipientState(
addressTextField = SendTextField.RecipientAddress(
value = "",
onValueChange = {},
keyboardOptions = KeyboardOptions.Default,
placeholder = stringReference("Enter address"),
label = stringReference("Recipient"),
isError = false,
error = null,
isValuePasted = false,
),
memoTextField = SendTextField.RecipientMemo(
value = "",
onValueChange = {},
keyboardOptions = KeyboardOptions.Default,
placeholder = stringReference("Optional"),
label = stringReference("Memo"),
isError = false,
error = null,
disabledText = stringReference("Already included in the entered address"),
isEnabled = true,
isValuePasted = false,
),
recent = persistentListOf(),
wallets = persistentListOf(),
network = "Ethereum",
isValidating = false,
isPrimaryButtonEnabled = true,
)
val recipientAddressState = recipientState.copy(
addressTextField = recipientState.addressTextField.copy(
value = "0x391316d97a07027a0702c8A002c8A0C25d8470",
),
)
val recipientWithRecentState = recipientState.copy(
recent = persistentListOf(
defaultRecentItem.copy(
id = "1",
subtitle = stringReference("1 000 000 000.0004 USDT"),
timestamp = stringReference("today at 14:46"),
subtitleIconRes = R.drawable.ic_arrow_up_24,
),
defaultRecentItem.copy(
id = "2",
subtitle = stringReference("20,09 USDT"),
timestamp = stringReference("24.05.2004 at 14:46"),
),
defaultRecentItem.copy(
id = "3",
subtitle = stringReference("20,09 USDT"),
timestamp = stringReference("24.05.2004 at 14:46"),
),
),
wallets = persistentListOf(
defaultRecentItem.copy(
id = "4",
subtitle = stringReference("Main Wallet"),
subtitleIconRes = null,
),
),
)
}

View file

@ -0,0 +1,74 @@
package com.tangem.features.send.impl.presentation.state.previewdata
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import java.math.BigDecimal
@Suppress("TooManyFunctions")
internal object SendClickIntentsStub : SendClickIntents {
override fun popBackStack() {}
override fun onBackClick() {}
override fun onCloseClick() {}
override fun onNextClick(isFromEdit: Boolean) {}
override fun onPrevClick() {}
override fun onQrCodeScanClick() {}
override fun onFailedTxEmailClick(errorMessage: String) {}
override fun onTokenDetailsClick(currency: CryptoCurrency) {}
override fun onAmountValueChange(value: String) {}
override fun onCurrencyChangeClick(isFiat: Boolean) {}
override fun onAmountNext() {}
override fun onMaxValueClick() {}
override fun onAmountPasteTriggerDismiss() {}
override fun onRecipientAddressValueChange(value: String, type: EnterAddressSource?) {}
override fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean) {}
override fun feeReload() {}
override fun onFeeSelectorClick(feeType: FeeType) {}
override fun onCustomFeeValueChange(index: Int, value: String) {}
override fun onReadMoreClick() {}
override fun onSendClick() {}
override fun showAmount() {}
override fun showRecipient() {}
override fun showFee() {}
override fun showSend() {}
override fun onExploreClick() {}
override fun onShareClick(txUrl: String) {}
override fun onAmountReduceByClick(
reduceAmountBy: BigDecimal,
reduceAmountByDiff: BigDecimal,
notification: Class<out NotificationUM>,
) {
}
override fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class<out NotificationUM>) {}
override fun onNotificationCancel(clazz: Class<out NotificationUM>) {}
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.send.impl.presentation.state.previewdata
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.core.ui.event.consumedEvent
import com.tangem.features.send.impl.presentation.state.SendUiState
internal object SendStatesPreviewData {
val uiState = SendUiState(
clickIntents = SendClickIntentsStub,
isEditingDisabled = false,
cryptoCurrencyName = "",
amountState = AmountStatePreviewData.amountWithValueState,
recipientState = RecipientStatePreviewData.recipientAddressState,
feeState = FeeStatePreviewData.feeChoosableState,
sendState = ConfirmStatePreviewData.sendState,
editAmountState = AmountStatePreviewData.amountWithValueState,
editRecipientState = RecipientStatePreviewData.recipientAddressState,
editFeeState = FeeStatePreviewData.feeChoosableState,
isBalanceHidden = false,
isSubtracted = false,
event = consumedEvent(),
)
}

View file

@ -0,0 +1,195 @@
package com.tangem.features.send.impl.presentation.state.recipient
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.transaction.error.ValidateAddressError
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toPersistentList
import timber.log.Timber
internal class RecipientSendFactory(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val isUtxoConsolidationAvailableProvider: Provider<Boolean>,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
) {
private val recipientWalletListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientWalletListConverter(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
isUtxoConsolidationAvailableProvider = isUtxoConsolidationAvailableProvider,
)
}
private val recipientHistoryListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientHistoryListConverter(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
fun onLoadedWalletsList(wallets: List<AvailableWallet?>): SendUiState {
val state = currentStateProvider()
return state.copy(
recipientState = state.recipientState?.copy(
wallets = recipientWalletListStateConverter.convert(wallets),
),
)
}
fun onLoadedHistoryList(txHistory: List<TxHistoryItem>): SendUiState {
val state = currentStateProvider()
return state.copy(
recipientState = state.recipientState?.copy(
recent = recipientHistoryListStateConverter.convert(txHistory),
),
)
}
fun onRecipientAddressValueChange(value: String, isXAddress: Boolean = false, isValuePasted: Boolean): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
addressTextField = recipientState.addressTextField.copy(value = value, isValuePasted = isValuePasted),
memoTextField = recipientState.memoTextField?.copy(isEnabled = !isXAddress),
),
)
}
fun getOnRecipientAddressValidState(
value: String,
maybeValidAddress: Either<ValidateAddressError, Unit>,
): SendUiState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
val isValidMemo = validateWalletMemoUseCase(
memo = recipientState.memoTextField?.value.orEmpty(),
network = cryptoCurrencyStatus.currency.network,
).getOrElse {
Timber.e("Failed to validateWalletMemoUseCase: $it")
false
}
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && maybeValidAddress.isRight(),
isValidating = false,
addressTextField = recipientState.addressTextField.copy(
error = maybeValidAddress.fold(
ifLeft = {
when (it) {
ValidateAddressError.InvalidAddress -> resourceReference(
R.string.send_recipient_address_error,
)
ValidateAddressError.AddressInWallet -> resourceReference(
R.string.send_error_address_same_as_wallet,
)
else -> null
}
},
ifRight = { null },
),
isError = value.isNotEmpty() && maybeValidAddress.isLeft(),
),
),
)
}
fun getOnRecipientAddressValidationStarted(): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(isValidating = true),
)
}
fun getOnRecipientMemoValueChange(value: String, isValuePasted: Boolean): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
memoTextField = recipientState.memoTextField?.copy(
value = value,
isValuePasted = isValuePasted,
),
),
)
}
fun getOnRecipientMemoValidState(value: String, isValidAddress: Boolean): SendUiState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
val isValidMemo = validateWalletMemoUseCase(
memo = value,
network = cryptoCurrencyStatus.currency.network,
).getOrElse {
Timber.e("Failed to validateWalletMemoUseCase: $it")
false
}
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
isPrimaryButtonEnabled = isValidMemo && isValidAddress,
isValidating = false,
memoTextField = recipientState.memoTextField?.copy(
isError = value.isNotEmpty() && !isValidMemo,
isEnabled = true,
),
),
)
}
fun getOnXAddressMemoState(): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
memoTextField = recipientState.memoTextField?.copy(
value = "",
isEnabled = false,
),
),
)
}
fun getHiddenRecentListState(isNotValid: Boolean): SendUiState {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val recipientState = state.getRecipientState(isEditState) ?: return state
return state.copyWrapped(
isEditState = isEditState,
recipientState = recipientState.copy(
recent = recipientState.recent.map { recent ->
recent.copy(isVisible = isNotValid && (recent.isLoading || recent.title != TextReference.EMPTY))
}.toPersistentList(),
wallets = recipientState.wallets.map { wallet ->
wallet.copy(isVisible = isNotValid && (wallet.isLoading || wallet.title != TextReference.EMPTY))
}.toPersistentList(),
),
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.send.impl.presentation.state.recipient
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
internal class SendRecipientAddressFieldConverter(
private val clickIntents: SendClickIntents,
) : Converter<String, SendTextField.RecipientAddress> {
override fun convert(value: String): SendTextField.RecipientAddress {
return SendTextField.RecipientAddress(
value = value,
onValueChange = clickIntents::onRecipientAddressValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Text,
),
error = resourceReference(R.string.send_recipient_address_error),
placeholder = resourceReference(R.string.send_enter_address_field),
label = resourceReference(R.string.send_recipient),
isValuePasted = false,
)
}
}

View file

@ -0,0 +1,94 @@
package com.tangem.features.send.impl.presentation.state.recipient
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.recipient.utils.RECENT_DEFAULT_COUNT
import com.tangem.features.send.impl.presentation.state.recipient.utils.RECENT_KEY_TAG
import com.tangem.features.send.impl.presentation.state.recipient.utils.emptyListState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
internal class SendRecipientHistoryListConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<List<TxHistoryItem>, ImmutableList<SendRecipientListContent>> {
override fun convert(value: List<TxHistoryItem>): ImmutableList<SendRecipientListContent> {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
return value.filterRecipients(cryptoCurrency).ifEmpty {
emptyListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT)
}
}
private fun List<TxHistoryItem>.filterRecipients(cryptoCurrency: CryptoCurrency) = this.filter { item ->
val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer
val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User
val isSingleAddress = if (item.isOutgoing) {
item.destinationType is TxHistoryItem.DestinationType.Single
} else {
item.sourceType is TxHistoryItem.SourceType.Single
}
val notZero = !item.amount.isZero()
isTransfer && isSingleAddress && isNotContract && item.isOutgoing && notZero
}
.take(RECENT_LIST_SIZE)
.mapIndexed { index, tx ->
SendRecipientListContent(
id = "$RECENT_KEY_TAG$index",
title = tx.extractAddress(),
subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()),
timestamp = tx.extractTimestamp(),
subtitleEndOffset = cryptoCurrency.symbol.length,
subtitleIconRes = tx.extractIconRes(),
)
}.toPersistentList()
private fun TxHistoryItem.extractAddress(): TextReference = if (isOutgoing) {
when (val destination = destinationType) {
is TxHistoryItem.DestinationType.Multiple -> TextReference.Res(
R.string.transaction_history_multiple_addresses,
)
is TxHistoryItem.DestinationType.Single -> TextReference.Str(destination.addressType.address)
}
} else {
when (val source = sourceType) {
is TxHistoryItem.SourceType.Multiple -> TextReference.Res(R.string.transaction_history_multiple_addresses)
is TxHistoryItem.SourceType.Single -> TextReference.Str(source.address)
}
}
private fun TxHistoryItem.extractIconRes() = if (isOutgoing) {
R.drawable.ic_arrow_up_24
} else {
R.drawable.ic_arrow_down_24
}
private fun TxHistoryItem.getAmount(cryptoCurrency: CryptoCurrency): String {
return amount.format { crypto(cryptoCurrency) }
}
private fun TxHistoryItem.extractTimestamp(): TextReference {
val date = timestampInMillis.toDateFormatWithTodayYesterday(
formatter = DateTimeFormatters.dateDDMMYYYY,
)
val time = timestampInMillis.toTimeFormat()
return TextReference.Res(R.string.send_date_format, wrappedList(date, time))
}
companion object {
private const val RECENT_LIST_SIZE = 10
}
}

View file

@ -0,0 +1,60 @@
package com.tangem.features.send.impl.presentation.state.recipient
import androidx.annotation.StringRes
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SendRecipientMemoFieldConverter(
private val clickIntents: SendClickIntents,
private val cryptoCurrencyStatus: Provider<CryptoCurrencyStatus>,
) : Converter<SendRecipientMemoFieldConverter.Data, SendTextField.RecipientMemo> {
fun convertOrNull(memoValue: String?): SendTextField.RecipientMemo? {
val cryptoCurrency = cryptoCurrencyStatus().currency
val memo = memoValue ?: ""
return when (cryptoCurrency.network.transactionExtrasType) {
Network.TransactionExtrasType.NONE -> null
Network.TransactionExtrasType.MEMO -> convert(
value = Data(
memo = memo,
label = R.string.send_extras_hint_memo,
),
)
Network.TransactionExtrasType.DESTINATION_TAG -> convert(
value = Data(
memo = memo,
label = R.string.send_destination_tag_field,
),
)
}
}
override fun convert(value: Data): SendTextField.RecipientMemo {
return SendTextField.RecipientMemo(
value = value.memo,
onValueChange = clickIntents::onRecipientMemoValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Text,
),
placeholder = resourceReference(R.string.send_optional_field),
label = resourceReference(value.label),
error = resourceReference(R.string.send_memo_destination_tag_error),
disabledText = resourceReference(R.string.send_additional_field_already_included),
isEnabled = true,
isValuePasted = false,
)
}
data class Data(val memo: String, @StringRes val label: Int)
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.send.impl.presentation.state.recipient
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.recipient.utils.*
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SendRecipientStateConverter(
private val clickIntents: SendClickIntents,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<SendRecipientStateConverter.Data, SendStates.RecipientState> {
private val addressFieldConverter by lazy { SendRecipientAddressFieldConverter(clickIntents) }
private val memoFieldConverter by lazy {
SendRecipientMemoFieldConverter(
clickIntents,
cryptoCurrencyStatusProvider,
)
}
override fun convert(value: Data): SendStates.RecipientState {
return SendStates.RecipientState(
addressTextField = addressFieldConverter.convert(value.address),
memoTextField = memoFieldConverter.convertOrNull(value.memo),
network = cryptoCurrencyStatusProvider().currency.network.name,
isPrimaryButtonEnabled = false,
wallets = loadingListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT),
recent = loadingListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT),
)
}
data class Data(val address: String, val memo: String? = null)
}

View file

@ -0,0 +1,65 @@
package com.tangem.features.send.impl.presentation.state.recipient
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.recipient.utils.WALLET_DEFAULT_COUNT
import com.tangem.features.send.impl.presentation.state.recipient.utils.WALLET_KEY_TAG
import com.tangem.features.send.impl.presentation.state.recipient.utils.emptyListState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal class SendRecipientWalletListConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val isUtxoConsolidationAvailableProvider: Provider<Boolean>,
) :
Converter<List<AvailableWallet?>, PersistentList<SendRecipientListContent>> {
override fun convert(value: List<AvailableWallet?>): PersistentList<SendRecipientListContent> {
return value.filterWallets().ifEmpty {
emptyListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT)
}
}
private fun List<AvailableWallet?>.filterWallets(): PersistentList<SendRecipientListContent> {
var walletsCounter = 0
val currentAddress: String = runCatching {
cryptoCurrencyStatusProvider().value.networkAddress?.defaultAddress?.value
}.getOrNull().orEmpty()
return this.filterNotNull()
.filter {
val isCoin = it.cryptoCurrency is CryptoCurrency.Coin
val isNotSameAddress = it.address != currentAddress
val isNotBlankAddress = it.address.isNotBlank()
isNotBlankAddress && isCoin && (isNotSameAddress || isUtxoConsolidationAvailableProvider())
}
.groupBy { item -> item.name }
.values.map { wallets ->
val groupedByWallet = wallets.groupBy { it.userWalletId }
var i = 0
groupedByWallet
.flatMap { item ->
item.value.map { wallet ->
val name = if (groupedByWallet.size > 1) {
"${wallet.name} ${++i}"
} else {
wallet.name
}
SendRecipientListContent(
id = "${WALLET_KEY_TAG}${walletsCounter++}",
title = TextReference.Str(wallet.address),
subtitle = TextReference.Str(name),
)
}
}
}
.flatten()
.toPersistentList()
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.send.impl.presentation.state.recipient.utils
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import kotlinx.collections.immutable.toPersistentList
internal const val WALLET_DEFAULT_COUNT = 1
internal const val RECENT_DEFAULT_COUNT = 3
internal const val WALLET_KEY_TAG = "wallet"
internal const val RECENT_KEY_TAG = "recent"
internal fun loadingListState(tag: String, count: Int) = buildList {
repeat(count) {
add(
SendRecipientListContent(
id = "$tag$it",
isLoading = true,
),
)
}
}.toPersistentList()
internal fun emptyListState(tag: String, count: Int) = buildList {
repeat(count) {
add(
SendRecipientListContent(
id = "$tag$it",
isLoading = false,
isVisible = false,
),
)
}
}.toPersistentList()

View file

@ -0,0 +1,79 @@
package com.tangem.features.send.impl.presentation.ui
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendEvent
@Composable
internal fun SendEventEffect(event: StateEvent<SendEvent>, snackbarHostState: SnackbarHostState) {
val resources = LocalContext.current.resources
var alertConfig by remember { mutableStateOf<AlertUM?>(value = null) }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(key1 = alertConfig) {
keyboardController?.hide()
}
alertConfig?.let {
SendAlert(state = it, onDismiss = { alertConfig = null })
}
EventEffect(
event = event,
onTrigger = { value ->
when (value) {
is SendEvent.ShowSnackBar -> {
snackbarHostState.showSnackbar(message = value.text.resolveReference(resources))
}
is SendEvent.ShowAlert -> {
alertConfig = value.alert
}
}
},
)
}
@Composable
internal fun SendAlert(state: AlertUM, onDismiss: () -> Unit) {
val confirmButton: DialogButtonUM
val dismissButton: DialogButtonUM?
val onActionClick = state.onConfirmClick
if (onActionClick != null) {
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = {
onActionClick()
onDismiss()
},
)
dismissButton = DialogButtonUM(
title = stringResourceSafe(id = R.string.common_cancel),
onClick = onDismiss,
)
} else {
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = onDismiss,
)
dismissButton = null
}
BasicDialog(
message = state.message.resolveReference(),
confirmButton = confirmButton,
onDismissDialog = onDismiss,
title = state.title?.resolveReference(),
dismissButton = dismissButton,
)
}

View file

@ -0,0 +1,309 @@
package com.tangem.features.send.impl.presentation.ui
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.ui.SendDoneButtons
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.tangem.core.ui.R
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
@Composable
internal fun SendNavigationButtons(
uiState: SendUiState,
currentState: SendUiCurrentScreen,
modifier: Modifier = Modifier,
) {
val sendState = uiState.sendState ?: return
val isSuccess = sendState.isSuccess
val isSendingState = currentState.type == SendUiStateType.Send && !isSuccess
val isSentState = currentState.type == SendUiStateType.Send && isSuccess
Column(
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
SendingText(
uiState = uiState,
isEditState = currentState.isFromConfirmation,
isVisible = isSendingState,
)
SendDoneButtons(
txUrl = sendState.txUrl,
onExploreClick = uiState.clickIntents::onExploreClick,
onShareClick = { uiState.clickIntents.onShareClick(it) },
isVisible = isSentState,
)
SendNavigationButton(
uiState = uiState,
currentState = currentState,
modifier = Modifier,
)
}
}
@Composable
private fun SendNavigationButton(
uiState: SendUiState,
currentState: SendUiCurrentScreen,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val sendState = uiState.sendState ?: return
val isEditingDisabled = uiState.isEditingDisabled
val isSuccess = sendState.isSuccess
val isSending = sendState.isSending
val isFromConfirmation = currentState.isFromConfirmation
val isCorrectScreen = currentState.type == SendUiStateType.Amount || currentState.type == SendUiStateType.Fee
val isSendingState = currentState.type == SendUiStateType.Send && !isSuccess && !isSending
val (buttonTextId, buttonClick) = getButtonData(
currentState = currentState,
isSuccess = isSuccess,
isSending = isSending,
uiState = uiState,
)
val isButtonEnabled = isButtonEnabled(currentState, uiState)
val buttonIcon = if (isSendingState) {
TangemButtonIconPosition.End(R.drawable.ic_tangem_24)
} else {
TangemButtonIconPosition.None
}
Row(modifier = modifier) {
AnimatedVisibility(
visible = !isEditingDisabled && isCorrectScreen && !isFromConfirmation,
enter = expandHorizontally(expandFrom = Alignment.End),
exit = shrinkHorizontally(shrinkTowards = Alignment.End),
) {
Row {
Icon(
painter = painterResource(R.drawable.ic_back_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.background(TangemTheme.colors.button.secondary)
.clickable { uiState.clickIntents.onPrevClick() }
.padding(TangemTheme.dimens.spacing12),
)
SpacerW12()
}
}
TangemButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(buttonTextId),
icon = buttonIcon,
enabled = isButtonEnabled,
onClick = {
if (isSendingState) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
buttonClick()
},
showProgress = false,
colors = TangemButtonsDefaults.primaryButtonColors,
textStyle = TangemTheme.typography.subtitle1,
)
}
}
@Composable
private fun SendingText(
uiState: SendUiState,
isEditState: Boolean,
isVisible: Boolean,
modifier: Modifier = Modifier,
) {
var isVisibleProxy by remember { mutableStateOf(isVisible) }
val keyboard by keyboardAsState()
// the text should appear when the keyboard is closed
LaunchedEffect(isVisible, keyboard) {
if (isVisible && keyboard is Keyboard.Opened) {
return@LaunchedEffect
}
isVisibleProxy = isVisible
}
AnimatedVisibility(
visible = isVisibleProxy,
modifier = modifier,
enter = slideInVertically() + fadeIn(),
exit = fadeOut(tween(durationMillis = 300)),
label = "Animate show sending state text",
) {
val amountState = uiState.getAmountState(isEditState) as? AmountState.Data
val feeState = uiState.getFeeState(isEditState)
val fiatRate = feeState?.rate
val fiatAmount = amountState?.amountTextField?.fiatAmount
val feeFiat = fiatRate?.let { feeState.fee?.amount?.value?.multiply(it) }
val sendingFiat = if (uiState.isSubtracted) {
fiatAmount?.value
} else {
if (feeState?.isFeeConvertibleToFiat == true) {
feeFiat?.let { fiatAmount?.value?.plus(it) }
} else {
fiatAmount?.value
}
}
if (feeFiat != null && sendingFiat != null) {
val sendingValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = sendingFiat,
fiatCurrencySymbol = feeState.appCurrency.symbol,
fiatCurrencyCode = feeState.appCurrency.code,
)
val textResource = remember(uiState) {
val fee = feeState.fee
if (feeState.isTronToken && fee is Fee.Tron) {
getTokenFeeSendingText(
feeState = feeState,
fee = fee,
sendingValue = sendingValue,
)
} else {
resourceReference(
id = if (feeState.isFeeConvertibleToFiat) {
R.string.send_summary_transaction_description
} else {
R.string.send_summary_transaction_description_no_fiat_fee
},
formatArgs = wrappedList(sendingValue, feeState.getFiatValue()),
)
}
}
Text(
text = textResource.resolveAnnotatedReference(),
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing12),
)
}
}
}
private fun SendStates.FeeState.getFiatValue() = if (isFeeConvertibleToFiat) {
getFiatString(
value = fee?.amount?.value,
rate = rate,
appCurrency = appCurrency,
)
} else {
val amount = fee?.amount
amount?.value.format {
crypto(
decimals = amount?.decimals ?: 0,
symbol = amount?.currencySymbol.orEmpty(),
).fee(
canBeLower = isFeeApproximate,
)
}
}
private fun getButtonData(
uiState: SendUiState,
currentState: SendUiCurrentScreen,
isSuccess: Boolean,
isSending: Boolean,
): Pair<Int, () -> Unit> {
return when (currentState.type) {
SendUiStateType.None,
SendUiStateType.Amount,
SendUiStateType.Recipient,
SendUiStateType.Fee,
-> R.string.common_next to { uiState.clickIntents.onNextClick() }
SendUiStateType.EditFee,
SendUiStateType.EditAmount,
SendUiStateType.EditRecipient,
-> R.string.common_continue to { uiState.clickIntents.onNextClick(isFromEdit = true) }
SendUiStateType.Send -> when {
isSuccess -> R.string.common_close
isSending -> R.string.send_sending
else -> R.string.common_send
} to uiState.clickIntents::onSendClick
}
}
private fun isButtonEnabled(currentState: SendUiCurrentScreen, uiState: SendUiState): Boolean {
return when (currentState.type) {
SendUiStateType.Amount -> uiState.amountState.isPrimaryButtonEnabled
SendUiStateType.Recipient -> uiState.recipientState?.isPrimaryButtonEnabled
SendUiStateType.Fee -> uiState.feeState?.isPrimaryButtonEnabled
SendUiStateType.Send -> uiState.sendState?.isPrimaryButtonEnabled
SendUiStateType.EditAmount -> uiState.editAmountState.isPrimaryButtonEnabled
SendUiStateType.EditRecipient -> uiState.editRecipientState?.isPrimaryButtonEnabled
SendUiStateType.EditFee -> uiState.editFeeState?.isPrimaryButtonEnabled
else -> true
} ?: false
}
private fun getTokenFeeSendingText(feeState: SendStates.FeeState, fee: Fee.Tron, sendingValue: String): TextReference {
val suffix = when {
fee.remainingEnergy == 0L -> {
resourceReference(
R.string.send_summary_transaction_description_suffix_including,
wrappedList(feeState.getFiatValue()),
)
}
fee.feeEnergy <= fee.remainingEnergy -> {
resourceReference(
R.string.send_summary_transaction_description_suffix_fee_covered,
wrappedList(fee.feeEnergy),
)
}
else -> {
resourceReference(
R.string.send_summary_transaction_description_suffix_fee_reduced,
wrappedList(fee.remainingEnergy),
)
}
}
val prefix = resourceReference(
R.string.send_summary_transaction_description_prefix,
wrappedList(sendingValue),
)
return combinedReference(prefix, COMMA_SEPARATOR, suffix)
}
private val COMMA_SEPARATOR = stringReference(", ")

View file

@ -0,0 +1,240 @@
package com.tangem.features.send.impl.presentation.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.amountScreen.AmountScreenContent
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.previewdata.ConfirmStatePreviewData
import com.tangem.features.send.impl.presentation.state.previewdata.SendStatesPreviewData
import com.tangem.features.send.impl.presentation.ui.fee.SendSpeedAndFeeContent
import com.tangem.features.send.impl.presentation.ui.recipient.SendRecipientContent
import com.tangem.features.send.impl.presentation.ui.send.SendContent
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.withIndex
@Composable
internal fun SendScreen(uiState: SendUiState, currentState: SendUiCurrentScreen) {
val snackbarHostState = remember { SnackbarHostState() }
val onBackClick = uiState.clickIntents::onBackClick.takeIf {
uiState.sendState?.isSending != true
} ?: {}
BackHandler(onBack = onBackClick)
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SendAppBar(
uiState = uiState,
currentState = currentState,
)
SendScreenContent(
uiState = uiState,
currentState = currentState,
modifier = Modifier.weight(1f),
)
SendNavigationButtons(
uiState = uiState,
currentState = currentState,
)
}
SendEventEffect(
event = uiState.event,
snackbarHostState = snackbarHostState,
)
}
@Composable
private fun SendAppBar(uiState: SendUiState, currentState: SendUiCurrentScreen) {
val (titleRes, subtitleRes) = when (currentState.type) {
SendUiStateType.Amount,
SendUiStateType.EditAmount,
-> resourceReference(R.string.send_amount_label) to null
SendUiStateType.Recipient,
SendUiStateType.EditRecipient,
-> resourceReference(R.string.send_recipient_label) to null
SendUiStateType.Fee,
SendUiStateType.EditFee,
-> resourceReference(R.string.common_fee_selector_title) to null
SendUiStateType.Send -> if (uiState.sendState?.isSuccess == false) {
resourceReference(R.string.send_summary_title, wrappedList(uiState.cryptoCurrencyName)) to
(uiState.amountState as? AmountState.Data)?.title
} else {
null to null
}
else -> null to null
}
val iconRes = if (currentState.type == SendUiStateType.Recipient) {
R.drawable.ic_qrcode_scan_24
} else {
null
}
val backIcon = when (currentState.type) {
SendUiStateType.EditAmount,
SendUiStateType.EditFee,
SendUiStateType.EditRecipient,
-> R.drawable.ic_back_24
else -> R.drawable.ic_close_24
}
AppBarWithBackButtonAndIcon(
text = titleRes?.resolveReference(),
subtitle = subtitleRes?.resolveReference(),
onBackClick = uiState.clickIntents::onCloseClick,
onIconClick = uiState.clickIntents::onQrCodeScanClick,
backIconRes = backIcon,
iconRes = iconRes,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun SendScreenContent(uiState: SendUiState, currentState: SendUiCurrentScreen, modifier: Modifier = Modifier) {
var currentStateProxy by remember { mutableStateOf(currentState) }
var isTransitionAnimationRunning by remember { mutableStateOf(false) }
// Prevent quick screen changes to avoid some of the transition animation distortions
LaunchedEffect(currentState) {
snapshotFlow { isTransitionAnimationRunning }
.withIndex()
.map { (index, running) ->
if (running && index != 0) {
delay(timeMillis = 200)
}
running
}
.first { !it }
currentStateProxy = currentState
}
// Restrict pressing the back button while screen transition is running to avoid most of the animation distortions
BackHandler(enabled = isTransitionAnimationRunning) {}
// Box is needed to fix animation with resizing of AnimatedContent
Box(modifier = modifier.fillMaxSize()) {
AnimatedContent(
targetState = currentStateProxy,
contentAlignment = Alignment.TopCenter,
label = "Send Scree Navigation",
transitionSpec = {
val direction = if (initialState.type.ordinal < targetState.type.ordinal) {
AnimatedContentTransitionScope.SlideDirection.Start
} else {
AnimatedContentTransitionScope.SlideDirection.End
}
slideIntoContainer(towards = direction, animationSpec = tween())
.togetherWith(slideOutOfContainer(towards = direction, animationSpec = tween()))
},
) { state ->
isTransitionAnimationRunning = transition.targetState != transition.currentState
when (state.type) {
SendUiStateType.Amount -> AmountScreenContent(
amountState = uiState.amountState,
isBalanceHidden = uiState.isBalanceHidden,
clickIntents = uiState.clickIntents,
modifier = Modifier.background(TangemTheme.colors.background.tertiary),
)
SendUiStateType.EditAmount -> AmountScreenContent(
amountState = uiState.editAmountState,
isBalanceHidden = uiState.isBalanceHidden,
clickIntents = uiState.clickIntents,
modifier = Modifier.background(TangemTheme.colors.background.tertiary),
)
SendUiStateType.Recipient -> SendRecipientContent(
uiState = uiState.recipientState,
clickIntents = uiState.clickIntents,
isBalanceHidden = uiState.isBalanceHidden,
)
SendUiStateType.EditRecipient -> SendRecipientContent(
uiState = uiState.editRecipientState,
clickIntents = uiState.clickIntents,
isBalanceHidden = uiState.isBalanceHidden,
)
SendUiStateType.EditFee -> SendSpeedAndFeeContent(
state = uiState.editFeeState,
clickIntents = uiState.clickIntents,
)
SendUiStateType.Send -> SendContent(uiState)
else -> Unit
}
}
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 736)
@Preview(showBackground = true, widthDp = 360, heightDp = 736, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendScreen_Preview(@PreviewParameter(SendScreenPreviewProvider::class) data: SendScreenPreview) {
TangemThemePreview {
SendScreen(
uiState = data.uiState,
currentState = data.currentState,
)
}
}
private class SendScreenPreviewProvider : PreviewParameterProvider<SendScreenPreview> {
override val values: Sequence<SendScreenPreview>
get() = sequenceOf(
SendScreenPreview(
uiState = SendStatesPreviewData.uiState,
currentState = SendUiCurrentScreen(type = SendUiStateType.Recipient, isFromConfirmation = false),
),
SendScreenPreview(
uiState = SendStatesPreviewData.uiState,
currentState = SendUiCurrentScreen(type = SendUiStateType.Amount, isFromConfirmation = false),
),
SendScreenPreview(
uiState = SendStatesPreviewData.uiState,
currentState = SendUiCurrentScreen(type = SendUiStateType.Send, isFromConfirmation = false),
),
SendScreenPreview(
uiState = SendStatesPreviewData.uiState,
currentState = SendUiCurrentScreen(type = SendUiStateType.EditFee, isFromConfirmation = true),
),
SendScreenPreview(
uiState = SendStatesPreviewData.uiState.copy(sendState = ConfirmStatePreviewData.sendDoneState),
currentState = SendUiCurrentScreen(type = SendUiStateType.Send, isFromConfirmation = false),
),
)
}
private data class SendScreenPreview(
val uiState: SendUiState,
val currentState: SendUiCurrentScreen,
)
// endregion

View file

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

View file

@ -0,0 +1,84 @@
package com.tangem.features.send.impl.presentation.ui.fee
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 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.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.core.ui.components.containers.FooterContainer
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun SendCustomFee(
customValues: ImmutableList<SendTextField.CustomFee>,
selectedFee: FeeType,
hasNotifications: Boolean,
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) {
TangemTheme.dimens.spacing12
} else {
TangemTheme.dimens.spacing0
}
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
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 = value.onValueChange,
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 = value.onValueChange,
keyboardOptions = value.keyboardOptions,
keyboardActions = value.keyboardActions,
showDivider = false,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
}
}
}
}

View file

@ -0,0 +1,111 @@
package com.tangem.features.send.impl.presentation.ui.fee
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.previewdata.FeeStatePreviewData
import com.tangem.features.send.impl.presentation.state.previewdata.SendClickIntentsStub
import com.tangem.features.send.impl.presentation.ui.common.notifications
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
private const val FEE_SELECTOR_KEY = "FEE_SELECTOR_KEY"
private const val FEE_CUSTOM_KEY = "FEE_CUSTOM_KEY"
@Composable
internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: SendClickIntents) {
if (state == null) return
val feeSendState = state.feeSelectorState as? FeeSelectorState.Content
val notifications = state.notifications
val isCustomSelected = feeSendState?.selectedFee == FeeType.Custom
val hasNotifications = notifications.isNotEmpty()
LazyColumn(
modifier = Modifier // Do not put fillMaxSize() in here
.background(TangemTheme.colors.background.tertiary)
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
feeSelector(state, clickIntents)
if (feeSendState != null) {
customFee(feeSendState = feeSendState, hasNotifications = hasNotifications)
}
notifications(notifications = notifications, hasPaddingAbove = isCustomSelected)
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.feeSelector(state: SendStates.FeeState, clickIntents: SendClickIntents) {
item(
key = FEE_SELECTOR_KEY,
) {
SendSpeedSelector(
state = state,
clickIntents = clickIntents,
modifier = Modifier.animateItemPlacement(),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.customFee(
feeSendState: FeeSelectorState.Content,
hasNotifications: Boolean,
modifier: Modifier = Modifier,
) {
item(
key = FEE_CUSTOM_KEY,
) {
SendCustomFee(
customValues = feeSendState.customValues,
selectedFee = feeSendState.selectedFee,
hasNotifications = hasNotifications,
modifier = modifier
.fillMaxWidth()
.animateItemPlacement()
.background(TangemTheme.colors.background.tertiary)
.padding(top = TangemTheme.dimens.spacing12),
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendSpeedAndFeeContent_Preview(
@PreviewParameter(FeeStatePreviewProvider::class) feeState: SendStates.FeeState,
) {
TangemThemePreview {
SendSpeedAndFeeContent(
state = feeState,
clickIntents = SendClickIntentsStub,
)
}
}
private class FeeStatePreviewProvider : PreviewParameterProvider<SendStates.FeeState> {
override val values: Sequence<SendStates.FeeState>
get() = sequenceOf(
FeeStatePreviewData.feeState,
FeeStatePreviewData.feeChoosableState,
FeeStatePreviewData.feeCustomState,
FeeStatePreviewData.errorFeeState,
)
}
// endregion

View file

@ -0,0 +1,129 @@
package com.tangem.features.send.impl.presentation.ui.fee
import android.content.res.Configuration
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.previewdata.FeeStatePreviewData
import com.tangem.features.send.impl.presentation.state.previewdata.SendClickIntentsStub
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
@Suppress("LongMethod")
@Composable
internal fun SendSpeedSelector(
state: SendStates.FeeState,
clickIntents: SendClickIntents,
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 = TangemTheme.dimens.spacing8),
onClick = click,
)
}
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendSpeedSelectorPreview(
@PreviewParameter(SendSpeedSelectorPreviewProvider::class) feeState: SendStates.FeeState,
) {
TangemThemePreview {
SendSpeedSelector(state = feeState, clickIntents = SendClickIntentsStub)
}
}
private class SendSpeedSelectorPreviewProvider : PreviewParameterProvider<SendStates.FeeState> {
override val values: Sequence<SendStates.FeeState>
get() = sequenceOf(
FeeStatePreviewData.feeState,
FeeStatePreviewData.errorFeeState,
)
}
// endregion

View file

@ -0,0 +1,151 @@
package com.tangem.features.send.impl.presentation.ui.fee
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 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.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.BigDecimalFormatter
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
@Composable
internal fun SendSpeedSelectorItem(
@StringRes titleRes: Int,
@DrawableRes iconRes: Int,
feeType: FeeType,
state: SendStates.FeeState,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
val feeSelectorState = state.feeSelectorState
val content = feeSelectorState as? FeeSelectorState.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?.selectedFee == feeType,
showDivider = showDivider,
)
FeeLoading(feeSelectorState)
FeeError(feeSelectorState)
}
}
}
@Composable
private fun FeeLoading(feeSelectorState: FeeSelectorState) {
Row {
SpacerWMax()
AnimatedVisibility(
visible = feeSelectorState == FeeSelectorState.Loading,
label = "Fee Loading State Change",
modifier = Modifier.align(Alignment.CenterVertically),
) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier
.padding(
vertical = TangemTheme.dimens.spacing18,
horizontal = TangemTheme.dimens.spacing12,
)
.size(
height = TangemTheme.dimens.size12,
width = TangemTheme.dimens.size90,
),
)
}
}
}
@Composable
private fun FeeError(feeSelectorState: FeeSelectorState) {
Row {
SpacerWMax()
AnimatedVisibility(
visible = feeSelectorState is FeeSelectorState.Error,
label = "Fee Error State Change",
modifier = Modifier.align(Alignment.CenterVertically),
) {
Text(
text = BigDecimalFormatter.EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
modifier = Modifier
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing12,
),
)
}
}
}
private fun FeeSelectorState.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 FeeSelectorState.Content?.getDividerAndVisibility(feeType: FeeType): Pair<Boolean, Boolean> {
val hasCustomValues = !this?.customValues.isNullOrEmpty()
val isNotSingle = this?.fees !is TransactionFee.Single
return when (feeType) {
FeeType.Slow -> true to isNotSingle
FeeType.Market -> (isNotSingle || hasCustomValues) to true
FeeType.Fast -> hasCustomValues to isNotSingle
FeeType.Custom -> false to hasCustomValues
}
}

View file

@ -0,0 +1,244 @@
package com.tangem.features.send.impl.presentation.ui.recipient
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
/**
* Row item with title and subtitle
*
* @param title title
* @param subtitle subtitle
* @param onClick click listener
* @param modifier modifier
* @param info info
* @param subtitleEndOffset offset for subtitle ellipsis
* @param subtitleIconRes icon
*/
@Composable
fun ListItemWithIcon(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
info: String? = null,
subtitleEndOffset: Int = 0,
@DrawableRes subtitleIconRes: Int? = null,
isLoading: Boolean = false,
) {
AnimatedContent(
targetState = isLoading,
label = "Recent List Content Animation",
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
) { isLoadingState ->
if (isLoadingState) {
ListItemLoading(modifier = modifier)
} else {
ListItemWithIcon(
title = title,
subtitle = subtitle,
onClick = onClick,
info = info,
subtitleEndOffset = subtitleEndOffset,
subtitleIconRes = subtitleIconRes,
modifier = modifier,
)
}
}
}
@Composable
private fun ListItemWithIcon(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
info: String? = null,
subtitleEndOffset: Int = 0,
@DrawableRes subtitleIconRes: Int? = null,
) {
val hapticFeedback = rememberHapticFeedback(state = title, onAction = onClick)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.clickable { hapticFeedback() }
.padding(horizontal = TangemTheme.dimens.spacing12),
) {
IdentIcon(
address = title,
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing8)
.size(TangemTheme.dimens.size40)
.clip(RoundedCornerShape(TangemTheme.dimens.radius20)),
)
Column(
modifier = Modifier
.height(TangemTheme.dimens.size36)
.padding(start = TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.SpaceBetween,
) {
EllipsisText(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Justify,
ellipsis = TextEllipsis.Middle,
modifier = Modifier,
)
Row {
if (subtitleIconRes != null) {
Icon(
painter = painterResource(id = subtitleIconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing2)
.size(TangemTheme.dimens.size16)
.background(TangemTheme.colors.background.tertiary, CircleShape)
.padding(TangemTheme.dimens.spacing2),
)
}
val (text, offset) = remember(subtitle, info) {
if (info != null) {
val suffix = ", $info"
subtitle + suffix to suffix.length + subtitleEndOffset
} else {
subtitle to 0
}
}
EllipsisText(
text = text,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset),
)
}
}
}
}
@Composable
private fun ListItemLoading(modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing12),
) {
CircleShimmer(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing8)
.size(TangemTheme.dimens.size40),
)
Column(
modifier = Modifier
.height(TangemTheme.dimens.size36)
.padding(start = TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.SpaceBetween,
) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier.size(
width = TangemTheme.dimens.spacing70,
height = TangemTheme.dimens.spacing12,
),
)
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier.size(
width = TangemTheme.dimens.spacing52,
height = TangemTheme.dimens.spacing12,
),
)
}
}
}
// region preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ListItemWithIconPreview(
@PreviewParameter(ListItemWithIconPreviewProvider::class) config: ListItemWithIconPreviewConfig,
) {
TangemThemePreview {
ListItemWithIcon(
title = config.title,
subtitle = config.subtitle,
subtitleEndOffset = config.subtitleEndOffset,
subtitleIconRes = config.iconRes,
onClick = {},
isLoading = config.isLoading,
)
}
}
private data class ListItemWithIconPreviewConfig(
val title: String,
val subtitle: String,
val info: String? = null,
val subtitleEndOffset: Int = 0,
val iconRes: Int? = null,
val isLoading: Boolean = false,
)
private class ListItemWithIconPreviewProvider : CollectionPreviewParameterProvider<ListItemWithIconPreviewConfig>(
collection = listOf(
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "0.000000000000000000000000000000 BTC",
info = "0.0.0000 at 00:00",
subtitleEndOffset = "BTC".length,
iconRes = R.drawable.ic_arrow_down_24,
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "1 BTC",
info = "0.0.0000 at 00:00",
subtitleEndOffset = "BTC".length,
iconRes = R.drawable.ic_arrow_down_24,
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "Wallet",
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "0.000000000000000000000000000000 BTC",
info = "0.0.0000 at 00:00",
subtitleEndOffset = "BTC".length,
iconRes = R.drawable.ic_arrow_down_24,
isLoading = true,
),
),
)
//endregion

View file

@ -0,0 +1,279 @@
package com.tangem.features.send.impl.presentation.ui.recipient
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.containers.FooterContainer
import com.tangem.core.ui.components.inputrow.InputRowRecipient
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.state.previewdata.RecipientStatePreviewData
import com.tangem.features.send.impl.presentation.state.previewdata.SendClickIntentsStub
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY"
private const val MEMO_FIELD_KEY = "MEMO_FIELD_KEY"
@Composable
internal fun SendRecipientContent(
uiState: SendStates.RecipientState?,
clickIntents: SendClickIntents,
isBalanceHidden: Boolean,
) {
if (uiState == null) return
val recipients = uiState.recent
val wallets = uiState.wallets
val memoField = uiState.memoTextField
val address = uiState.addressTextField
val isValidating by remember(uiState.isValidating) { derivedStateOf { uiState.isValidating } }
val isError by remember(address.isError) { derivedStateOf { address.isError } }
LazyColumn(
modifier = Modifier // Do not put fillMaxSize() in here
.background(TangemTheme.colors.background.tertiary)
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
addressItem(
address = address,
network = uiState.network,
isError = isError,
isValidating = isValidating,
onAddressChange = clickIntents::onRecipientAddressValueChange,
)
memoField(
memoField = memoField,
onMemoChange = { clickIntents.onRecipientMemoValueChange(it, true) },
)
listHeaderItem(
titleRes = R.string.send_recipient_wallets_title,
isVisible = wallets.isNotEmpty() && wallets.first().isVisible,
isFirst = true,
)
listItem(
list = wallets,
clickIntents = clickIntents,
isLast = recipients.any { !it.isVisible },
isBalanceHidden = isBalanceHidden,
)
listHeaderItem(
titleRes = R.string.send_recent_transactions,
isVisible = recipients.isNotEmpty() && recipients.first().isVisible,
isFirst = wallets.any { !it.isVisible },
)
listItem(
list = recipients,
clickIntents = clickIntents,
isLast = true,
isBalanceHidden = isBalanceHidden,
)
}
}
private fun LazyListScope.addressItem(
address: SendTextField.RecipientAddress,
network: String,
isError: Boolean,
isValidating: Boolean,
onAddressChange: (String, EnterAddressSource?) -> Unit,
) {
item(key = ADDRESS_FIELD_KEY) {
FooterContainer(
footer = resourceReference(R.string.send_recipient_address_footer, wrappedList(network)),
) {
InputRowRecipient(
value = address.value,
title = address.label,
placeholder = address.placeholder,
onValueChange = address.onValueChange,
onPasteClick = { onAddressChange(it, EnterAddressSource.PasteButton) },
isError = isError,
isLoading = isValidating,
error = address.error,
isValuePasted = address.isValuePasted,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
}
private fun LazyListScope.memoField(memoField: SendTextField.RecipientMemo?, onMemoChange: (String) -> Unit) {
if (memoField != null) {
item(key = MEMO_FIELD_KEY) {
val placeholder = if (memoField.isEnabled) memoField.placeholder else memoField.disabledText
TextFieldWithPaste(
value = memoField.value,
label = memoField.label,
placeholder = placeholder,
footer = resourceReference(R.string.send_recipient_memo_footer),
onValueChange = memoField.onValueChange,
onPasteClick = onMemoChange,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing20),
labelStyle = TangemTheme.typography.subtitle2,
isError = memoField.isError,
error = memoField.error,
isReadOnly = !memoField.isEnabled,
isValuePasted = memoField.isValuePasted,
)
}
}
}
private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Boolean, isFirst: Boolean) {
item(key = titleRes) {
AnimateRecentAppearance(isVisible) {
val (topPadding, paddingFromTop) = if (isFirst) {
TangemTheme.dimens.spacing20 to TangemTheme.dimens.spacing12
} else {
TangemTheme.dimens.spacing0 to TangemTheme.dimens.spacing8
}
val topRadius = if (isFirst) {
TangemTheme.dimens.radius16
} else {
TangemTheme.dimens.radius0
}
Text(
text = stringResourceSafe(titleRes),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(top = topPadding)
.clip(
RoundedCornerShape(
topEnd = topRadius,
topStart = topRadius,
),
)
.background(TangemTheme.colors.background.action)
.padding(
top = paddingFromTop,
bottom = TangemTheme.dimens.spacing12,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
)
}
}
}
private fun LazyListScope.listItem(
list: ImmutableList<SendRecipientListContent>,
clickIntents: SendClickIntents,
isLast: Boolean,
isBalanceHidden: Boolean,
) {
items(
count = list.size,
key = { list[it].id },
contentType = { list[it]::class.java },
) { index ->
val item = list[index]
val title = item.title.resolveReference()
AnimateRecentAppearance(item.isVisible) {
ListItemWithIcon(
title = title,
subtitle = item.subtitle.orMaskWithStars(isBalanceHidden).resolveReference(),
info = item.timestamp?.resolveReference(),
subtitleEndOffset = item.subtitleEndOffset,
subtitleIconRes = item.subtitleIconRes,
onClick = {
clickIntents.onRecipientAddressValueChange(
title,
EnterAddressSource.RecentAddress,
)
},
isLoading = item.isLoading,
modifier = Modifier
.then(
if (isLast && index == list.lastIndex) {
Modifier
.clip(
shape = RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius16,
bottomEnd = TangemTheme.dimens.radius16,
),
)
} else {
Modifier
},
)
.background(TangemTheme.colors.background.action),
)
}
}
}
@Composable
private fun AnimateRecentAppearance(isVisible: Boolean, content: @Composable () -> Unit) {
AnimatedContent(
targetState = isVisible,
label = "Item Appearance Animation",
transitionSpec = {
(slideInHorizontally() + fadeIn())
.togetherWith(slideOutVertically() + fadeOut())
},
) {
if (it) {
content()
} else {
Box(modifier = Modifier.fillMaxWidth())
}
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendRecipientContent_Preview(
@PreviewParameter(SendRecipientContentPreviewProvider::class) recipientState: SendStates.RecipientState,
) {
TangemThemePreview {
SendRecipientContent(
uiState = recipientState,
clickIntents = SendClickIntentsStub,
isBalanceHidden = false,
)
}
}
private class SendRecipientContentPreviewProvider : PreviewParameterProvider<SendStates.RecipientState> {
override val values: Sequence<SendStates.RecipientState>
get() = sequenceOf(
RecipientStatePreviewData.recipientWithRecentState,
RecipientStatePreviewData.recipientState,
RecipientStatePreviewData.recipientAddressState,
)
}
// endregion

View file

@ -0,0 +1,102 @@
package com.tangem.features.send.impl.presentation.ui.recipient
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterEnd
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.components.inputrow.inner.CrossIcon
import com.tangem.core.ui.components.inputrow.inner.PasteButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.components.containers.FooterContainer
@Composable
internal fun TextFieldWithPaste(
value: String,
placeholder: TextReference,
label: TextReference,
onValueChange: (String) -> Unit,
onPasteClick: (String) -> Unit,
modifier: Modifier = Modifier,
footer: TextReference? = null,
labelStyle: TextStyle = TangemTheme.typography.body2,
error: TextReference? = null,
isError: Boolean = false,
isReadOnly: Boolean = false,
isValuePasted: Boolean = false,
) {
val (title, color) = when {
isError && error != null -> error to TangemTheme.colors.text.warning
isReadOnly -> label to TangemTheme.colors.text.tertiary
else -> label to TangemTheme.colors.text.secondary
}
val placeholderColor = if (isReadOnly) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.disabled
FooterContainer(modifier, footer) {
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(end = TangemTheme.dimens.spacing12),
) {
Row {
Column(
modifier = Modifier
.weight(1f)
.padding(TangemTheme.dimens.spacing12),
) {
Text(
text = title.resolveReference(),
style = labelStyle,
color = color,
)
SimpleTextField(
value = value,
placeholder = placeholder,
placeholderColor = placeholderColor,
onValueChange = onValueChange,
readOnly = isReadOnly,
isValuePasted = isValuePasted,
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing8),
)
}
AnimatedVisibility(
visible = !isReadOnly,
label = "Animate read only status change",
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(CenterVertically),
) {
CrossIcon(
onClick = onPasteClick,
)
}
}
AnimatedVisibility(
visible = !isReadOnly,
label = "Animate read only status change",
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(CenterEnd),
) {
PasteButton(
isPasteButtonVisible = value.isBlank(),
onClick = onPasteClick,
)
}
}
}
}

View file

@ -0,0 +1,149 @@
package com.tangem.features.send.impl.presentation.ui.send
import android.content.res.Configuration
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.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.res.TangemThemePreview
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.previewdata.FeeStatePreviewData
@Composable
internal fun FeeBlock(feeState: SendStates.FeeState, isClickDisabled: Boolean, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(enabled = !isClickDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing12),
) {
Text(
text = stringResourceSafe(R.string.common_network_fee_title),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.secondary,
)
Box(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
) {
val feeSelectorState = feeState.feeSelectorState
val feeAmount = feeState.fee?.amount
val (title, icon) = if (feeSelectorState is FeeSelectorState.Content) {
when (feeSelectorState.selectedFee) {
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 = feeState.isFeeApproximate)
},
),
postDot = if (feeState.isFeeConvertibleToFiat) {
getFiatReference(feeAmount?.value, feeState.rate, feeState.appCurrency)
} else {
null
},
ellipsizeOffset = feeAmount?.currencySymbol?.length,
isSelected = true,
showDivider = false,
showSelectedAppearance = false,
paddingValues = PaddingValues(),
)
FeeLoading(feeSelectorState)
FeeError(feeSelectorState)
}
}
}
@Composable
private fun BoxScope.FeeLoading(feeSelectorState: FeeSelectorState) {
AnimatedContent(
targetState = feeSelectorState,
label = "Fee Loading State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it == FeeSelectorState.Loading) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier.size(
height = TangemTheme.dimens.size12,
width = TangemTheme.dimens.size90,
),
)
}
}
}
@Composable
private fun BoxScope.FeeError(feeSelectorState: FeeSelectorState) {
AnimatedContent(
targetState = feeSelectorState,
label = "Fee Error State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it is FeeSelectorState.Error) {
Text(
text = BigDecimalFormatter.EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
)
}
}
}
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: SendStates.FeeState) {
TangemThemePreview {
FeeBlock(
feeState = value,
isClickDisabled = true,
onClick = {},
)
}
}
private class FeeBlockPreviewProvider : PreviewParameterProvider<SendStates.FeeState> {
override val values: Sequence<SendStates.FeeState>
get() = sequenceOf(
FeeStatePreviewData.feeState,
)
}
// endregion

View file

@ -0,0 +1,124 @@
package com.tangem.features.send.impl.presentation.ui.send
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.state.previewdata.RecipientStatePreviewData
@Composable
internal fun RecipientBlock(
recipientState: SendStates.RecipientState,
isClickDisabled: Boolean,
isEditingDisabled: Boolean,
onClick: () -> Unit,
) {
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 = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing12),
) {
AddressBlock(recipientState.addressTextField)
MemoBlock(recipientState.memoTextField)
}
}
@Composable
private fun AddressBlock(address: SendTextField.RecipientAddress) {
Text(
text = address.label.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.secondary,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
) {
IdentIcon(
address = address.value,
modifier = Modifier
.size(TangemTheme.dimens.size36)
.clip(RoundedCornerShape(TangemTheme.dimens.radius18))
.background(TangemTheme.colors.background.tertiary),
)
Text(
text = address.value,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
}
@Composable
private fun MemoBlock(memo: SendTextField.RecipientMemo?) {
val showMemo = memo != null && memo.value.isNotBlank()
if (showMemo) {
HorizontalDivider(
color = TangemTheme.colors.icon.inactive,
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12),
)
Text(
text = memo?.label?.resolveReference().orEmpty(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = memo?.value.orEmpty(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
)
}
}
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun RecipientBlockPreview(
@PreviewParameter(RecipientBlockPreviewProvider::class) value: SendStates.RecipientState,
) {
TangemThemePreview {
RecipientBlock(
recipientState = value,
isClickDisabled = true,
isEditingDisabled = false,
onClick = {},
)
}
}
private class RecipientBlockPreviewProvider : PreviewParameterProvider<SendStates.RecipientState> {
override val values: Sequence<SendStates.RecipientState>
get() = sequenceOf(
RecipientStatePreviewData.recipientState,
)
}
// endregion

View file

@ -0,0 +1,173 @@
package com.tangem.features.send.impl.presentation.ui.send
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.previewdata.ConfirmStatePreviewData
import com.tangem.features.send.impl.presentation.state.previewdata.SendStatesPreviewData
import com.tangem.features.send.impl.presentation.ui.common.notifications
import kotlinx.coroutines.delay
private const val TAP_HELP_KEY = "TAP_HELP_KEY"
private const val BLOCKS_KEY = "BLOCKS_KEY"
private const val TAP_HELP_ANIMATION_DELAY = 500L
@Suppress("LongMethod")
@Composable
internal fun SendContent(uiState: SendUiState) {
val sendState = uiState.sendState ?: return
val isClickDisabled = sendState.isSending || sendState.isSuccess
LazyColumn(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
blocks(uiState)
tapHelp(isDisplay = sendState.showTapHelp)
notifications(notifications = sendState.notifications, isClickDisabled = isClickDisabled)
}
}
private fun LazyListScope.blocks(uiState: SendUiState) {
val amountState = uiState.amountState
val recipientState = uiState.recipientState ?: return
val feeState = uiState.feeState ?: return
val sendState = uiState.sendState ?: return
val isSuccess = sendState.isSuccess
val isClickDisabled = sendState.isSending || isSuccess
val timestamp = sendState.transactionDate
item(key = BLOCKS_KEY) {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12)) {
AnimatedVisibility(
visible = isSuccess,
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12),
) {
TransactionDoneTitle(
title = resourceReference(R.string.sent_transaction_sent_title),
subtitle = resourceReference(
R.string.send_date_format,
wrappedList(
timestamp.toTimeFormat(DateTimeFormatters.dateFormatter),
timestamp.toTimeFormat(),
),
),
)
}
RecipientBlock(
recipientState = recipientState,
isClickDisabled = isClickDisabled,
isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showRecipient,
)
AmountBlock(
amountState = amountState,
isClickDisabled = isClickDisabled,
isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showAmount,
)
FeeBlock(
feeState = feeState,
isClickDisabled = isClickDisabled,
onClick = uiState.clickIntents::showFee,
)
}
}
}
private fun LazyListScope.tapHelp(isDisplay: Boolean, modifier: Modifier = Modifier) {
item(key = TAP_HELP_KEY) {
val animationState = remember { MutableTransitionState(false) }
LaunchedEffect(key1 = isDisplay) {
delay(TAP_HELP_ANIMATION_DELAY)
animationState.targetState = isDisplay
}
AnimatedVisibility(
visibleState = animationState,
label = "Tap Help Animation",
enter = slideInVertically(
initialOffsetY = { it / 2 },
).plus(fadeIn()),
exit = slideOutVertically().plus(fadeOut()),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.padding(top = TangemTheme.dimens.spacing20),
) {
val background = TangemTheme.colors.button.secondary
Icon(
painter = painterResource(id = R.drawable.send_hint_shape_12),
tint = TangemTheme.colors.button.secondary,
contentDescription = null,
modifier = Modifier,
)
Text(
text = stringResourceSafe(id = R.string.send_summary_tap_hint),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(background)
.padding(
horizontal = TangemTheme.dimens.spacing14,
vertical = TangemTheme.dimens.spacing12,
),
)
}
}
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendContent_Preview(@PreviewParameter(SendContentPreviewProvider::class) uiState: SendUiState) {
TangemThemePreview {
SendContent(
uiState = uiState,
)
}
}
private class SendContentPreviewProvider : PreviewParameterProvider<SendUiState> {
override val values: Sequence<SendUiState>
get() = sequenceOf(
SendStatesPreviewData.uiState,
SendStatesPreviewData.uiState.copy(sendState = ConfirmStatePreviewData.sendDoneState),
)
}
// endregion

View file

@ -0,0 +1,70 @@
package com.tangem.features.send.impl.presentation.viewmodel
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import java.math.BigDecimal
@Suppress("TooManyFunctions")
internal interface SendClickIntents : AmountScreenClickIntents {
fun popBackStack()
fun onBackClick()
fun onCloseClick()
fun onNextClick(isFromEdit: Boolean = false)
fun onPrevClick()
fun onQrCodeScanClick()
fun onFailedTxEmailClick(errorMessage: String)
fun onTokenDetailsClick(currency: CryptoCurrency)
// region Recipient
fun onRecipientAddressValueChange(value: String, type: EnterAddressSource? = null)
fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean = false)
// endregion
// region Fee
fun feeReload()
fun onFeeSelectorClick(feeType: FeeType)
fun onCustomFeeValueChange(index: Int, value: String)
fun onReadMoreClick()
// endregion
// region Send
fun onSendClick()
fun showAmount()
fun showRecipient()
fun showFee()
fun showSend()
fun onExploreClick()
fun onShareClick(txUrl: String)
fun onAmountReduceByClick(
reduceAmountBy: BigDecimal,
reduceAmountByDiff: BigDecimal,
notification: Class<out NotificationUM>,
)
fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class<out NotificationUM>)
fun onNotificationCancel(clazz: Class<out NotificationUM>)
// endregion
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="12dp"
android:height="7dp"
android:viewportWidth="12"
android:viewportHeight="7">
<path
android:pathData="M4.988,0.785C3.819,3.428 1.89,7 0,7H12C10.115,7 8.269,3.447 7.157,0.806C6.784,-0.08 5.376,-0.094 4.988,0.785Z"
android:fillColor="#EBEBEB"/>
</vector>