Updated on 2026-08-14
This commit is contained in:
commit
383f1abc4f
2127 changed files with 89632 additions and 12987 deletions
|
|
@ -20,24 +20,18 @@ import com.tangem.core.decompose.context.AppComponentContext
|
|||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.decompose.navigation.inner.InnerRouter
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
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.utils.parseBigDecimalOrNull
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.isHotWallet
|
||||
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.model.SwapModel
|
||||
import com.tangem.feature.swap.models.SwapPermissionUM
|
||||
import com.tangem.feature.swap.router.SwapRoute
|
||||
import com.tangem.feature.swap.ui.SwapScreen
|
||||
import com.tangem.feature.swap.ui.SwapSuccessScreen
|
||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalEntryComponent
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
|
||||
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.swap.SwapComponent
|
||||
import com.tangem.utils.isNullOrZero
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
|
|
@ -50,7 +44,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: SwapComponent.Params,
|
||||
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
|
||||
private val giveApprovalComponentFactory: GiveApprovalComponent.Factory,
|
||||
private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory,
|
||||
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
|
||||
) : SwapComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
|
|
@ -78,12 +72,10 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
source = model.approvalSlotNavigation,
|
||||
serializer = null,
|
||||
handleBackButton = true,
|
||||
childFactory = { _, factoryContext ->
|
||||
val approvalParams = getApprovalParams()
|
||||
?: error("Approval params are not available")
|
||||
giveApprovalComponentFactory.create(
|
||||
childFactory = { params, factoryContext ->
|
||||
giveApprovalEntryComponentFactory.create(
|
||||
context = childByContext(factoryContext),
|
||||
params = approvalParams,
|
||||
params = GiveApprovalEntryComponent.Params(params),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -123,6 +115,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
analyticsCategoryName = CommonSendAnalyticEvents.SWAP_CATEGORY,
|
||||
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap,
|
||||
),
|
||||
isTransferMode = config.isTransferMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -143,6 +136,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
data class FeeSelectorConfig(
|
||||
val sendingCurrencyStatus: CryptoCurrencyStatus,
|
||||
val feeCurrencyStatus: CryptoCurrencyStatus,
|
||||
val isTransferMode: Boolean,
|
||||
)
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
|
|
@ -151,23 +145,22 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle()
|
||||
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } }
|
||||
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
|
||||
val isInTransferMode by remember { derivedStateOf { dataState.currentTransferState != null } }
|
||||
val shouldHideBlock by remember {
|
||||
derivedStateOf {
|
||||
// TODO collapse this and move to model
|
||||
val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero()
|
||||
val isInsufficientFunds = model.uiState.isInsufficientFunds
|
||||
val isProviderMissing = dataState.selectedProvider == null
|
||||
val loadedState = dataState.getCurrentLoadedSwapState()
|
||||
val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty
|
||||
val isInTransferMode = dataState.currentTransferState != null
|
||||
val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady)
|
||||
val isPermissionNotNeeded = model.isPermissionNotNeeded
|
||||
val isSwapNotReady = !isInTransferMode && (isProviderMissing || !isPermissionNotNeeded)
|
||||
val isTangemPayWithdrawal = model.isTangemPayWithdrawal()
|
||||
|
||||
isAmountEmptyOrZero || isInsufficientFunds || isSwapNotReady || isTangemPayWithdrawal
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) {
|
||||
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock, isInTransferMode) {
|
||||
if (shouldHideBlock) {
|
||||
TangemLogger.e(
|
||||
messageString = "Dismissing fee selector: " +
|
||||
|
|
@ -195,6 +188,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
FeeSelectorConfig(
|
||||
sendingCurrencyStatus = sendingCryptoCurrencyStatus,
|
||||
feeCurrencyStatus = feeCurrencyStatus,
|
||||
isTransferMode = isInTransferMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -247,34 +241,6 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun getApprovalParams(): GiveApprovalComponent.Params? {
|
||||
val permissionState = model.uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null
|
||||
val fromSwapCurrencyStatus = model.dataState.fromSwapCurrencyStatus ?: return null
|
||||
val feeCryptoCurrency = model.dataState.feePaidCryptoCurrency ?: return null
|
||||
val providerName = model.dataState.selectedProvider?.name.orEmpty()
|
||||
val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet
|
||||
|
||||
return GiveApprovalComponent.Params(
|
||||
userWalletId = params.userWalletId,
|
||||
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrency,
|
||||
amount = model.dataState.amount.orEmpty(),
|
||||
spenderAddress = permissionState.spenderAddress,
|
||||
amountFooter = if (permissionState.isResetApproval) {
|
||||
resourceReference(R.string.update_approval_permission_subtitle)
|
||||
} else {
|
||||
resourceReference(
|
||||
id = R.string.give_permission_swap_subtitle,
|
||||
formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol),
|
||||
)
|
||||
},
|
||||
feeFooter = resourceReference(R.string.swap_give_permission_fee_footer),
|
||||
isResetApproval = permissionState.isResetApproval,
|
||||
isHoldToConfirm = isHoldToConfirm,
|
||||
callback = model.approvalCallback,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onChildBack() {
|
||||
val isEmptyStack = childStack.value.backStack.isEmpty()
|
||||
val isSuccess = model.uiState.successState != null
|
||||
|
|
|
|||
|
|
@ -6,30 +6,50 @@ import com.tangem.features.swap.SwapFeatureToggles
|
|||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultSwapFeatureToggles @Inject constructor(
|
||||
featureTogglesManager: FeatureTogglesManager,
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
) : SwapFeatureToggles {
|
||||
|
||||
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
|
||||
)
|
||||
override val isYieldSwapEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapIntegratedApproveEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.SWAP_INTEGRATED_APPROVE,
|
||||
)
|
||||
override val isSwapSwitchToTransferEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapAbEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.SWAP_AB_ENABLED,
|
||||
)
|
||||
override val isSwapIntegratedApproveEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE,
|
||||
)
|
||||
|
||||
override val isSwapProviderFilterEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15009_SWAP_PROVIDER_FILTER_ENABLED,
|
||||
)
|
||||
override val isSwapAbEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.SWAP_AB_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapRateExperienceEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15103_SWAP_RATE_EXPERIENCE_ENABLED,
|
||||
)
|
||||
override val isSwapProviderFilterEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15009_SWAP_PROVIDER_FILTER_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapPredefinedButtonsEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED,
|
||||
)
|
||||
override val isSwapRateExperienceEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15103_SWAP_RATE_EXPERIENCE_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapPredefinedButtonsEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED,
|
||||
)
|
||||
override val isExpressShareButtonEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15489_EXPRESS_SHARE_BUTTON_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapBestDexRateEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15715_SWAP_BEST_DEX_RATE_ENABLED,
|
||||
) && isSwapIntegratedApproveEnabled
|
||||
}
|
||||
|
|
@ -1,20 +1,23 @@
|
|||
package com.tangem.feature.swap.analytics
|
||||
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
|
||||
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
|
||||
import com.tangem.core.analytics.models.getReferralParams
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.swap.models.PredefinedPercentAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
|
||||
|
|
@ -297,6 +300,65 @@ sealed class SwapEvents(
|
|||
event = "Fast amount input",
|
||||
params = mapOf("Percentage" to percent.toAnalyticsValue()),
|
||||
)
|
||||
|
||||
class TransferModeSwitched(
|
||||
fromCurrency: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
) : SwapEvents(
|
||||
event = "Transfer Mode Switched",
|
||||
params = mapOf(
|
||||
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
|
||||
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
|
||||
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
|
||||
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
|
||||
),
|
||||
)
|
||||
|
||||
class ButtonTransferClicked(
|
||||
fromCurrency: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
) : SwapEvents(
|
||||
event = "Button - Transfer",
|
||||
params = mapOf(
|
||||
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
|
||||
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
|
||||
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
|
||||
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
|
||||
),
|
||||
)
|
||||
|
||||
@Suppress("NullableToStringCall", "LongParameterList")
|
||||
class TransferInProgressScreen(
|
||||
fromCurrency: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
feeNetwork: Network,
|
||||
isTangemPay: Boolean,
|
||||
) : SwapEvents(
|
||||
event = "Transfer in Progress Screen Opened",
|
||||
params = mapOf(
|
||||
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
|
||||
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
|
||||
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
|
||||
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
|
||||
"Network fee" to feeNetwork.name,
|
||||
"Pay Account" to isTangemPay.toString(),
|
||||
),
|
||||
), AppsFlyerIncludedEvent
|
||||
|
||||
class ApproveGasOverrideError(
|
||||
fromTokenSymbol: String,
|
||||
fromTokenBlockchain: String,
|
||||
rpcProvider: String,
|
||||
error: String,
|
||||
) : SwapEvents(
|
||||
event = "Gas Estimation Override Error",
|
||||
params = mapOf(
|
||||
TOKEN_PARAM to fromTokenSymbol,
|
||||
BLOCKCHAIN to fromTokenBlockchain,
|
||||
"RPC Provider" to rpcProvider,
|
||||
ERROR_MESSAGE to error,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun PredefinedPercentAmount.toAnalyticsValue(): String = when (this) {
|
||||
|
|
|
|||
|
|
@ -12,18 +12,14 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.v2.api.params.FeeSelectorParams
|
||||
import com.tangem.features.send.api.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.params.FeeSelectorParams
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
class SwapFeeSelectorBlockComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
|
|
@ -44,7 +40,11 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
|
|||
null
|
||||
},
|
||||
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
|
||||
feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow,
|
||||
feeStateConfiguration = if (params.isTransferMode) {
|
||||
FeeSelectorParams.FeeStateConfiguration.None
|
||||
} else {
|
||||
FeeSelectorParams.FeeStateConfiguration.ExcludeLow
|
||||
},
|
||||
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
|
||||
cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus,
|
||||
analyticsCategoryName = params.analyticsParams.analyticsCategoryName,
|
||||
|
|
@ -100,6 +100,7 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
|
|||
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
val analyticsParams: AnalyticsParams,
|
||||
val repository: ModelRepository,
|
||||
val isTransferMode: Boolean,
|
||||
)
|
||||
|
||||
@AssistedFactory
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.model.consideredProvidersStates
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.models.states.ProviderState.AdditionalBadge
|
||||
import com.tangem.utils.isNullOrZero
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* Pure provider-level decisions for the swap UI:
|
||||
* - [findBest] — which provider is the "best" among the loaded quotes, and
|
||||
* - [resolveBadge] — which [ProviderState.AdditionalBadge] a provider row should show.
|
||||
*/
|
||||
internal object SwapProviderResolver {
|
||||
|
||||
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
|
||||
/**
|
||||
* Picks the best provider among [states].
|
||||
*
|
||||
* When [isSwapBestDexRateEnabled] is on and at least one DEX/DEX_BRIDGE provider is present, the
|
||||
* best-rated DEX provider wins; otherwise the overall best-rated provider is returned (the best
|
||||
* CEX when no DEX is available). "Best rated" = lowest from/to fiat ratio (most output per unit
|
||||
* of input). Returns null when [states] is empty.
|
||||
*
|
||||
* @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on.
|
||||
*/
|
||||
fun findBest(
|
||||
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
): SwapProvider? {
|
||||
if (!isSwapBestDexRateEnabled) return findBestRated(states)
|
||||
val dexStates = states.filterKeys { it.type.isDex() }
|
||||
return if (dexStates.isNotEmpty()) {
|
||||
findBestRated(dexStates)
|
||||
} else {
|
||||
findBestRated(states)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the badge for a single provider row.
|
||||
*
|
||||
* Priority: FCA restriction → permission required → recommended → best rate → none. A best-rate
|
||||
* badge is shown only when more than one provider is considered, FCA restrictions are not applied,
|
||||
* and this row's quote carries no price-impact warning. Which best-rate badge it is depends on the
|
||||
* provider mix (only relevant when [isSwapBestDexRateEnabled] is on):
|
||||
* - [AdditionalBadge.BestTrade] ("Best rate") — always on the overall best-rated provider,
|
||||
* regardless of its type.
|
||||
* - [AdditionalBadge.BestDexRate] ("Best DEX rate") — only when both CEX and DEX providers are
|
||||
* present and a CEX is the overall best (so the best DEX is not the overall best); it is then
|
||||
* shown on the best-rated DEX. When a DEX already is the overall best, or the set is CEX-only /
|
||||
* DEX-only, no separate "Best DEX rate" badge is shown.
|
||||
*
|
||||
* When [isSwapBestDexRateEnabled] is off, only the overall best provider gets [AdditionalBadge.BestTrade]
|
||||
* (legacy behaviour) and [AdditionalBadge.BestDexRate] is never produced.
|
||||
*
|
||||
* @param states all loaded quotes — used to find the best providers and to count considered providers.
|
||||
* @param provider the provider this row represents.
|
||||
* @param needApplyFCARestrictions whether FCA restrictions apply to the current user.
|
||||
* @param state this provider's [SwapState]; price-impact and permission are read from it when it
|
||||
* is a [SwapState.QuotesLoadedState]. Null for error rows (which only resolve to FCA / recommended / none).
|
||||
* @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on.
|
||||
*/
|
||||
fun resolveBadge(
|
||||
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
|
||||
provider: SwapProvider,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
state: SwapState? = null,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
): AdditionalBadge {
|
||||
val priceImpact = (state as? SwapState.QuotesLoadedState)?.priceImpact
|
||||
val permissionState = (state as? SwapState.QuotesLoadedState)?.permissionState
|
||||
|
||||
val isNeedBestRateBadge = states.consideredProvidersStates().size > 1
|
||||
val isBestRateBadgeAllowed = !needApplyFCARestrictions && isNeedBestRateBadge &&
|
||||
priceImpact != null && !priceImpact.shouldShowWarning()
|
||||
|
||||
return when {
|
||||
needApplyFCARestrictions && provider.isFCARestricted() -> AdditionalBadge.FCAWarningList
|
||||
permissionState is PermissionDataState.PermissionRequired -> AdditionalBadge.PermissionRequired
|
||||
provider.isRecommended -> AdditionalBadge.Recommended
|
||||
isBestRateBadgeAllowed -> resolveBestRateBadge(states, provider, isSwapBestDexRateEnabled)
|
||||
else -> AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the best-rate badge for [provider] once it has passed the eligibility gate in [resolveBadge].
|
||||
* Returns [AdditionalBadge.Empty] when this row is neither the overall best nor the eligible best DEX.
|
||||
*/
|
||||
private fun resolveBestRateBadge(
|
||||
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
|
||||
provider: SwapProvider,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
): AdditionalBadge {
|
||||
val overallBest = findBestRated(states)
|
||||
val isOverallBest = provider.providerId == overallBest?.providerId
|
||||
|
||||
// Toggle off → legacy behaviour: only the overall best provider gets the "Best rate" badge.
|
||||
if (!isSwapBestDexRateEnabled) {
|
||||
return if (isOverallBest) AdditionalBadge.BestTrade else AdditionalBadge.Empty
|
||||
}
|
||||
|
||||
val dexStates = states.filterKeys { it.type.isDex() }
|
||||
val hasDex = dexStates.isNotEmpty()
|
||||
val hasCex = states.keys.any { !it.type.isDex() }
|
||||
val bestDex = findBestRated(dexStates)
|
||||
val isBestDex = bestDex != null && provider.providerId == bestDex.providerId
|
||||
// Both types present and a CEX is the overall best (i.e. overall best != best DEX).
|
||||
val isCexBeatsDex = hasDex && hasCex && overallBest?.providerId != bestDex?.providerId
|
||||
|
||||
return when {
|
||||
isOverallBest -> AdditionalBadge.BestTrade
|
||||
isCexBeatsDex && isBestDex -> AdditionalBadge.BestDexRate
|
||||
else -> AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
/** Best provider following the default best-rate behaviour over all providers. */
|
||||
private fun findBestRated(states: Map<SwapProvider, SwapState.QuotesLoadedState>): SwapProvider? {
|
||||
return states.minByOrNull { entry -> entry.value.rateRatio() }?.key
|
||||
}
|
||||
|
||||
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
|
||||
|
||||
private fun SwapState.QuotesLoadedState.rateRatio(): BigDecimal {
|
||||
val fromAmountFiat = fromTokenInfo.amountFiat
|
||||
val toAmountFiat = toTokenInfo.amountFiat
|
||||
return if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) {
|
||||
fromAmountFiat.divide(
|
||||
toAmountFiat,
|
||||
toTokenInfo.swapCurrencyStatus.currency.decimals,
|
||||
RoundingMode.HALF_UP,
|
||||
)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,58 +7,39 @@ import com.tangem.core.ui.format.bigdecimal.crypto
|
|||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.states.PercentDifference
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
|
||||
/**
|
||||
* Builds [ProviderState.Content] for the swap provider list / row.
|
||||
*
|
||||
* Pure: takes everything it needs as parameters. Designed to be unit-tested in isolation.
|
||||
*/
|
||||
internal object SwapProviderStateBuilder {
|
||||
|
||||
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
|
||||
/**
|
||||
* Provider row on the main swap screen — shows the exchange rate `1 base ≈ rate quote`
|
||||
* (see [SwapRateFormatter]) and allows the user to open the provider picker.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun buildContentClickable(
|
||||
provider: SwapProvider,
|
||||
fromTokenInfo: TokenSwapInfo,
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
permissionState: PermissionDataState,
|
||||
state: SwapState.QuotesLoadedState,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
isBestRate: Boolean,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
val rateString = SwapRateFormatter.formatRate(
|
||||
from = fromTokenInfo.swapCurrencyStatus.currency,
|
||||
to = toTokenInfo.swapCurrencyStatus.currency,
|
||||
fromAmount = fromTokenInfo.tokenAmount.value,
|
||||
toAmount = toTokenInfo.tokenAmount.value,
|
||||
from = state.fromTokenInfo.swapCurrencyStatus.currency,
|
||||
to = state.toTokenInfo.swapCurrencyStatus.currency,
|
||||
fromAmount = state.fromTokenInfo.tokenAmount.value,
|
||||
toAmount = state.toTokenInfo.tokenAmount.value,
|
||||
)
|
||||
return provider.toContent(
|
||||
subtitle = stringReference(rateString),
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
permissionState = permissionState,
|
||||
isBestRate = isBestRate,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -70,28 +51,26 @@ internal object SwapProviderStateBuilder {
|
|||
@Suppress("LongParameterList")
|
||||
fun buildContentSelectable(
|
||||
provider: SwapProvider,
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
permissionState: PermissionDataState,
|
||||
state: SwapState.QuotesLoadedState,
|
||||
pricesLowerBest: Map<String, Float>,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
isBestRate: Boolean = false,
|
||||
isNeedBestRateBadge: Boolean = false,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
onProviderClick: (String) -> Unit,
|
||||
onApprovalSelectClick: (SwapProvider) -> Unit = {},
|
||||
): ProviderState.Content {
|
||||
return provider.toContent(
|
||||
subtitle = buildSelectableSubtitle(toTokenInfo),
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
permissionState = permissionState,
|
||||
isBestRate = isBestRate,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
),
|
||||
subtitle = buildSelectableSubtitle(state.toTokenInfo),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = pricesLowerBest[provider.providerId]
|
||||
?.let(PercentDifference::Value)
|
||||
?: PercentDifference.Value(0f),
|
||||
approvalSettings = when (state.permissionState) {
|
||||
is PermissionDataState.PermissionSettings -> ProviderState.ApprovalSettings.Content(
|
||||
onApprovalSelectClick = { onApprovalSelectClick(provider) },
|
||||
)
|
||||
else -> ProviderState.ApprovalSettings.Empty
|
||||
},
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -104,17 +83,15 @@ internal object SwapProviderStateBuilder {
|
|||
provider: SwapProvider,
|
||||
alertText: TextReference,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
return provider.toContent(
|
||||
subtitle = alertText,
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -130,32 +107,13 @@ internal object SwapProviderStateBuilder {
|
|||
return stringReference(toAmount)
|
||||
}
|
||||
|
||||
private fun resolveBadge(
|
||||
provider: SwapProvider,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
permissionState: PermissionDataState? = null,
|
||||
isBestRate: Boolean = false,
|
||||
isNeedBestRateBadge: Boolean = false,
|
||||
): ProviderState.AdditionalBadge {
|
||||
return when {
|
||||
needApplyFCARestrictions && provider.isFCARestricted() ->
|
||||
ProviderState.AdditionalBadge.FCAWarningList
|
||||
permissionState is PermissionDataState.PermissionRequired ->
|
||||
ProviderState.AdditionalBadge.PermissionRequired
|
||||
provider.isRecommended ->
|
||||
ProviderState.AdditionalBadge.Recommended
|
||||
isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions ->
|
||||
ProviderState.AdditionalBadge.BestTrade
|
||||
else ->
|
||||
ProviderState.AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun SwapProvider.toContent(
|
||||
subtitle: TextReference,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
percentLowerThenBest: PercentDifference,
|
||||
approvalSettings: ProviderState.ApprovalSettings,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
return ProviderState.Content(
|
||||
|
|
@ -169,8 +127,7 @@ internal object SwapProviderStateBuilder {
|
|||
percentLowerThenBest = percentLowerThenBest,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
onProviderClick = onProviderClick,
|
||||
approvalSettings = approvalSettings,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
|
||||
}
|
||||
|
|
@ -47,6 +47,9 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
* @param userWalletId the wallet to resolve currencies for
|
||||
* @param initialCryptoCurrency pre-selected currency, or null to auto-select
|
||||
* @param swapCurrencyPosition preferred position for the initial currency
|
||||
* @param initialToCryptoCurrency optional currency to pre-select as TO. It is placed into the TO slot
|
||||
* ONLY if it already exists in the user's crypto portfolio (and the TO slot wasn't filled otherwise);
|
||||
* if the currency is not added to the wallet, the TO slot stays empty.
|
||||
* @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
|
|
@ -54,6 +57,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
initialCryptoCurrency: CryptoCurrency?,
|
||||
swapCurrencyPosition: CurrencyPosition,
|
||||
isPaymentAccount: Boolean,
|
||||
initialToCryptoCurrency: CryptoCurrency? = null,
|
||||
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
|
||||
val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId)
|
||||
val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus ->
|
||||
|
|
@ -65,7 +69,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
|
||||
val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten()
|
||||
|
||||
return if (initialCryptoCurrency != null) {
|
||||
val (from, to) = if (initialCryptoCurrency != null) {
|
||||
val selectedSwapCurrencyStatus = if (isPaymentAccount) {
|
||||
cryptoPaymentAccounts
|
||||
} else {
|
||||
|
|
@ -91,6 +95,43 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
cryptoCurrencyList = cryptoCurrencyList,
|
||||
) to null
|
||||
}
|
||||
|
||||
val resolvedTo = to ?: resolveExplicitToCurrency(
|
||||
initialToCryptoCurrency = initialToCryptoCurrency,
|
||||
from = from,
|
||||
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
|
||||
)
|
||||
|
||||
return from to resolvedTo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the optional explicit TO currency, but only if it is already present in the user's crypto
|
||||
* portfolio. Matches by token identity ([isSameTokenAs]) rather than full id, since the passed currency
|
||||
* may come from a different account/derivation. Prefers the instance from the FROM account, then falls
|
||||
* back to the first match across the portfolio. Never returns the same token as FROM.
|
||||
*/
|
||||
private fun resolveExplicitToCurrency(
|
||||
initialToCryptoCurrency: CryptoCurrency?,
|
||||
from: SwapCurrencyStatus?,
|
||||
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
|
||||
): SwapCurrencyStatus? {
|
||||
if (initialToCryptoCurrency == null) return null
|
||||
|
||||
val fromCurrency = from?.currency
|
||||
fun matches(status: SwapCurrencyStatus): Boolean {
|
||||
return status.currency.isSameTokenAs(initialToCryptoCurrency) &&
|
||||
(fromCurrency == null || !status.currency.isSameTokenAs(fromCurrency))
|
||||
}
|
||||
|
||||
val fromAccountMatch = from?.account?.accountId?.let { fromAccountId ->
|
||||
cryptoPortfolioAccountsMap.entries
|
||||
.firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == fromAccountId }
|
||||
?.value
|
||||
?.firstOrNull(::matches)
|
||||
}
|
||||
|
||||
return fromAccountMatch ?: cryptoPortfolioAccountsMap.values.flatten().firstOrNull(::matches)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,6 +151,8 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
val currencyStatuses = when (accountStatus) {
|
||||
is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies()
|
||||
is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus)
|
||||
// Virtual account isn't a swap source in the MVP (withdrawal reuses the send flow)
|
||||
is AccountStatus.Virtual -> emptyList()
|
||||
}
|
||||
val availabilityStates = rampStateManager.availableForSwap(
|
||||
userWalletId,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,7 @@ import com.tangem.domain.express.models.ExpressError
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.AssetRequirementsCondition
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
|
|
@ -78,6 +79,19 @@ internal class SwapNotificationsFactory(
|
|||
)
|
||||
}
|
||||
|
||||
fun getDestinationRequirementNotifications(
|
||||
requirement: AssetRequirementsCondition,
|
||||
onAssociateClick: () -> Unit,
|
||||
): ImmutableList<NotificationUM> {
|
||||
val notification = when (requirement) {
|
||||
is AssetRequirementsCondition.RequiredTrustline ->
|
||||
SwapNotificationUM.Warning.TokenTrustlineRequired(onAssociateClick)
|
||||
else ->
|
||||
SwapNotificationUM.Warning.TokenAssociationRequired(onAssociateClick)
|
||||
}
|
||||
return persistentListOf(notification)
|
||||
}
|
||||
|
||||
fun getQuotesErrorStateNotifications(
|
||||
expressDataError: ExpressDataError,
|
||||
fromToken: CryptoCurrency,
|
||||
|
|
@ -208,6 +222,7 @@ internal class SwapNotificationsFactory(
|
|||
cryptoCurrency = swapCurrencyStatus.currency,
|
||||
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
|
||||
isAccountFunded = true, // consider the account is funded on the provider side
|
||||
hasRequiredTrustline = false,
|
||||
)
|
||||
addReduceAmountNotification(
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
|
|
@ -249,7 +264,7 @@ internal class SwapNotificationsFactory(
|
|||
if (quoteModel.permissionState is PermissionDataState.PermissionRequired) {
|
||||
add(
|
||||
SwapNotificationUM.Info.PermissionNeeded(
|
||||
onApproveClick = actions.openPermissionBottomSheet,
|
||||
onApproveClick = actions.onApproveClick,
|
||||
onLearnMoreClick = { actions.onLinkClick(TangemSiteUrlBuilder.HELP_CENTER_SWAP_URL) },
|
||||
),
|
||||
)
|
||||
|
|
@ -346,6 +361,15 @@ internal class SwapNotificationsFactory(
|
|||
}
|
||||
|
||||
when (feeError) {
|
||||
is GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError -> {
|
||||
add(
|
||||
getWarningForError(
|
||||
expressDataError = ExpressDataError.TooLargeSolanaTransactionError(),
|
||||
fromToken = quoteModel.fromTokenInfo.swapCurrencyStatus.currency,
|
||||
onRetryClick = actions.onRetryClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
is GetFeeError.DataError -> {
|
||||
val error = feeError.cause
|
||||
if (error is ExpressDataError) {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ package com.tangem.feature.swap.model
|
|||
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import java.math.BigDecimal
|
||||
|
||||
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
|
||||
|
||||
data class SwapProcessDataState(
|
||||
// Initial network id
|
||||
val fromSwapCurrencyStatus: SwapCurrencyStatus? = null,
|
||||
|
|
@ -30,4 +33,28 @@ data class SwapProcessDataState(
|
|||
fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? {
|
||||
return lastLoadedSwapStates[selectedProvider] as? SwapState.QuotesLoadedState
|
||||
}
|
||||
|
||||
fun getLastLoadedSuccessStates(): SuccessLoadedSwapData {
|
||||
return lastLoadedSwapStates.filter { entry -> entry.value is SwapState.QuotesLoadedState }
|
||||
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
|
||||
return this.filter { entry -> entry.value is SwapState.QuotesLoadedState }
|
||||
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
|
||||
}
|
||||
|
||||
internal fun Map<SwapProvider, SwapState>.consideredProvidersStates(): Map<SwapProvider, SwapState> {
|
||||
fun isUserResolvableError(swapState: SwapState): Boolean {
|
||||
return swapState is SwapState.SwapError &&
|
||||
(
|
||||
swapState.error is ExpressDataError.ExchangeTooSmallAmountError ||
|
||||
swapState.error is ExpressDataError.ExchangeTooBigAmountError
|
||||
)
|
||||
}
|
||||
|
||||
return this.filter { entry ->
|
||||
entry.value is SwapState.QuotesLoadedState || isUserResolvableError(entry.value)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.buttons.predefined.PredefinedPercentButtonUM
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
|
|
@ -16,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal data class SwapStateHolder(
|
||||
@param:StringRes val titleId: Int,
|
||||
val sendCardData: SwapCardState,
|
||||
val receiveCardData: SwapCardState,
|
||||
val notifications: ImmutableList<NotificationUM> = persistentListOf(),
|
||||
|
|
@ -46,6 +49,7 @@ internal data class SwapStateHolder(
|
|||
val onShowPermissionBottomSheet: () -> Unit = {},
|
||||
val onSwapUIModeChange: (SwapUIMode) -> Unit = {},
|
||||
val onSwapTypeMenuOpened: () -> Unit = {},
|
||||
val onTronBannerShown: () -> Unit = {},
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
|
@ -58,15 +62,16 @@ sealed class SwapCardState {
|
|||
val currencyIconState: CurrencyIconState,
|
||||
val tokenSymbol: TextReference,
|
||||
val amountEquivalent: TextReference?,
|
||||
val amountTextFieldValue: TextFieldValue?,
|
||||
val balance: TextReference,
|
||||
val isBalanceHidden: Boolean,
|
||||
val appCurrency: AppCurrency,
|
||||
val amountField: AmountFieldModel? = null,
|
||||
) : SwapCardState()
|
||||
|
||||
data class Empty(
|
||||
override val type: TransactionCardType,
|
||||
val amountEquivalent: TextReference,
|
||||
val amountTextFieldValue: TextFieldValue?,
|
||||
val amountField: AmountFieldModel? = null,
|
||||
) : SwapCardState()
|
||||
|
||||
data class Loading(
|
||||
|
|
@ -99,11 +104,12 @@ sealed interface TransactionCardType {
|
|||
val inputError: InputError
|
||||
|
||||
data class Inputtable(
|
||||
val onAmountChanged: ((String) -> Unit),
|
||||
val onFocusChanged: ((Boolean) -> Unit),
|
||||
override val inputError: InputError,
|
||||
override val accountTitleUM: AccountTitleUM,
|
||||
val isEnabled: Boolean,
|
||||
/** Switches the input field between crypto and fiat entry. Argument is the new `isFiatValue`. */
|
||||
val onCurrencyChange: (Boolean) -> Unit = {},
|
||||
) : TransactionCardType
|
||||
|
||||
data class ReadOnly(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.navigationButtons.NavigationUM
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
|
|
@ -22,7 +23,7 @@ data class SwapSuccessStateHolder(
|
|||
val toTokenFiatAmount: TextReference,
|
||||
val fromTokenIconState: CurrencyIconState?,
|
||||
val toTokenIconState: CurrencyIconState?,
|
||||
val onExploreButtonClick: () -> Unit,
|
||||
val navigationUM: NavigationUM,
|
||||
val onStatusButtonClick: () -> Unit,
|
||||
) {
|
||||
val shouldShowProvider: Boolean
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.express.models.ProviderFilterType
|
||||
import com.tangem.domain.swap.models.PredefinedPercentAmount
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal data class UiActions(
|
||||
val onAmountChanged: (String) -> Unit,
|
||||
val onCurrencyChange: (Boolean) -> Unit,
|
||||
val onAmountSelected: (Boolean) -> Unit,
|
||||
val onSwapClick: () -> Unit,
|
||||
val onTransferClick: () -> Unit,
|
||||
|
|
@ -18,7 +20,8 @@ internal data class UiActions(
|
|||
val onPredefinedPercentSelected: (PredefinedPercentAmount) -> Unit,
|
||||
val onReduceToAmount: (SwapAmount) -> Unit,
|
||||
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
|
||||
val openPermissionBottomSheet: () -> Unit,
|
||||
val onApproveClick: () -> Unit,
|
||||
val onApproveTypeSelect: (SwapProvider) -> Unit,
|
||||
// region new actions
|
||||
val onRetryClick: () -> Unit,
|
||||
val onProviderClick: (String) -> Unit,
|
||||
|
|
@ -31,4 +34,5 @@ internal data class UiActions(
|
|||
val onReceiveCardWarningClick: () -> Unit,
|
||||
val onSwapUIModeChange: (SwapUIMode) -> Unit,
|
||||
val onSwapTypeMenuOpened: () -> Unit,
|
||||
val onTronBannerShown: () -> Unit,
|
||||
)
|
||||
|
|
@ -29,6 +29,7 @@ sealed class ProviderState {
|
|||
val additionalBadge: AdditionalBadge,
|
||||
val percentLowerThenBest: PercentDifference = PercentDifference.Empty,
|
||||
val namePrefix: PrefixType,
|
||||
val approvalSettings: ApprovalSettings = ApprovalSettings.Empty,
|
||||
override val onProviderClick: (String) -> Unit,
|
||||
) : ProviderState()
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ sealed class ProviderState {
|
|||
sealed class AdditionalBadge {
|
||||
data object FCAWarningList : AdditionalBadge()
|
||||
data object BestTrade : AdditionalBadge()
|
||||
data object BestDexRate : AdditionalBadge()
|
||||
data object Empty : AdditionalBadge()
|
||||
data object PermissionRequired : AdditionalBadge()
|
||||
data object Recommended : AdditionalBadge()
|
||||
|
|
@ -61,6 +63,14 @@ sealed class ProviderState {
|
|||
enum class PrefixType {
|
||||
NONE, PROVIDED_BY
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class ApprovalSettings {
|
||||
data object Empty : ApprovalSettings()
|
||||
data class Content(
|
||||
val onApprovalSelectClick: () -> Unit,
|
||||
) : ApprovalSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -149,6 +149,28 @@ internal object SwapNotificationUM {
|
|||
),
|
||||
)
|
||||
|
||||
data class TokenAssociationRequired(
|
||||
val onAssociateClick: () -> Unit,
|
||||
) : Warning(
|
||||
title = resourceReference(R.string.warning_hedera_missing_token_association_title),
|
||||
subtitle = resourceReference(R.string.warning_receive_blocked_hedera_token_association_required_message),
|
||||
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
|
||||
text = resourceReference(R.string.warning_hedera_missing_token_association_button_title),
|
||||
onClick = onAssociateClick,
|
||||
),
|
||||
)
|
||||
|
||||
data class TokenTrustlineRequired(
|
||||
val onAssociateClick: () -> Unit,
|
||||
) : Warning(
|
||||
title = resourceReference(R.string.warning_token_trustline_title),
|
||||
subtitle = resourceReference(R.string.warning_receive_blocked_token_trustline_required_message),
|
||||
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
|
||||
text = resourceReference(R.string.warning_token_trustline_button_title),
|
||||
onClick = onAssociateClick,
|
||||
),
|
||||
)
|
||||
|
||||
data class NeedReserveToCreateAccount(
|
||||
val receiveAmount: String,
|
||||
val receiveToken: String,
|
||||
|
|
@ -251,5 +273,10 @@ internal object SwapNotificationUM {
|
|||
onClick = onApproveClick,
|
||||
),
|
||||
)
|
||||
|
||||
data object TronTokenFee : Info(
|
||||
title = resourceReference(R.string.tron_will_be_send_token_fee_title),
|
||||
subtitle = resourceReference(R.string.tron_will_be_send_token_fee_description),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference
|
|||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.models.SwapSuccessStateHolder
|
||||
import com.tangem.feature.swap.ui.swapSuccessNavigation
|
||||
|
||||
internal data object SwapSuccessStatePreview {
|
||||
val state = SwapSuccessStateHolder(
|
||||
|
|
@ -37,7 +38,7 @@ internal data object SwapSuccessStatePreview {
|
|||
fromTokenIconState = CurrencyIconState.Loading,
|
||||
toTokenIconState = CurrencyIconState.Loading,
|
||||
rate = TextReference.Str("1 000 DAI ~ 1 000 MATIC"),
|
||||
onExploreButtonClick = {},
|
||||
navigationUM = swapSuccessNavigation(txUrl = "https://www.google.com/#q=nam", exploreClick = {}),
|
||||
onStatusButtonClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.ParagraphIntrinsics
|
||||
import androidx.compose.ui.text.font.createFontFamilyResolver
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
@Suppress("MagicNumber", "LongMethod")
|
||||
@Composable
|
||||
internal fun AutoSizeTextField(
|
||||
textFieldValue: TextFieldValue,
|
||||
focusRequester: FocusRequester,
|
||||
isEnabled: Boolean,
|
||||
onAmountChange: (String) -> Unit,
|
||||
onFocusChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LaunchedEffect(isEnabled) {
|
||||
if (!isEnabled) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
var shrunkFontSize = TangemTheme.typography.h2.fontSize
|
||||
val calculateIntrinsics = @Composable {
|
||||
ParagraphIntrinsics(
|
||||
text = textFieldValue.text,
|
||||
style = TangemTheme.typography.h2.copy(
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
fontSize = shrunkFontSize,
|
||||
),
|
||||
density = LocalDensity.current,
|
||||
fontFamilyResolver = createFontFamilyResolver(LocalContext.current),
|
||||
)
|
||||
}
|
||||
|
||||
var intrinsics = calculateIntrinsics()
|
||||
with(LocalDensity.current) {
|
||||
while (intrinsics.maxIntrinsicWidth > maxWidth.toPx()) {
|
||||
shrunkFontSize *= 0.9f
|
||||
intrinsics = calculateIntrinsics()
|
||||
}
|
||||
}
|
||||
val customTextSelectionColors = TextSelectionColors(
|
||||
handleColor = Color.Transparent,
|
||||
backgroundColor = TangemTheme.colors.text.secondary.copy(alpha = 0.4f),
|
||||
)
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) {
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
onAmountChange.invoke(it.text)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { onFocusChange(it.hasFocus) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Decimal,
|
||||
),
|
||||
enabled = isEnabled,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
decorationBox = { innerTextField ->
|
||||
if (textFieldValue.text.isBlank()) {
|
||||
Text(
|
||||
text = "0",
|
||||
color = TangemTheme.colors.text.disabled,
|
||||
style = TangemTheme.typography.h2,
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
},
|
||||
textStyle = TangemTheme.typography.h2.copy(
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
fontSize = shrunkFontSize,
|
||||
),
|
||||
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,6 +178,7 @@ private fun Preview_ChooseProviderBottomSheet() {
|
|||
percentLowerThenBest = PercentDifference.Value(-1.0f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = {},
|
||||
),
|
||||
ProviderState.Unavailable(
|
||||
|
|
|
|||
|
|
@ -4,26 +4,32 @@ import android.content.res.Configuration
|
|||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.SpacerWMax
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
|
|
@ -154,6 +160,7 @@ private fun ProviderContentState(
|
|||
when (state.additionalBadge) {
|
||||
ProviderState.AdditionalBadge.FCAWarningList -> FCABadgeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.BestDexRate -> BestDexRateItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.Recommended -> RecommendedItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.Empty -> Unit
|
||||
|
|
@ -194,6 +201,23 @@ private fun ProviderContentState(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.approvalSettings is ProviderState.ApprovalSettings.Content) {
|
||||
SpacerWMax()
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_filter_default_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier
|
||||
.padding(end = 14.dp)
|
||||
.size(20.dp)
|
||||
.clickable(
|
||||
indication = ripple(false),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = state.approvalSettings.onApprovalSelectClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
|
||||
|
|
@ -380,6 +404,24 @@ private fun BestTradeItem(modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BestDexRateItem(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
|
||||
shape = TangemTheme.shapes.roundedCornersLarge,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.express_provider_best_dex_rate),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors.icon.accent,
|
||||
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionBadgeItem(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
|
|
@ -442,10 +484,9 @@ private fun ProviderItemPreview(
|
|||
@PreviewParameter(ProviderItemParameterProvider::class) state: Pair<ProviderState, Boolean>,
|
||||
) {
|
||||
TangemThemePreview {
|
||||
ProviderItem(
|
||||
ProviderItemBlock(
|
||||
modifier = Modifier.background(TangemTheme.colors.background.action),
|
||||
state = state.first,
|
||||
isSelected = state.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -460,22 +501,28 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
|
|||
subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
percentLowerThenBest = PercentDifference.Value(value = 12.0f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
selectionType = ProviderState.SelectionType.NONE,
|
||||
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = {},
|
||||
)
|
||||
val contentState2 = contentState.copy(
|
||||
val contentStatePermissionRequired = contentState.copy(
|
||||
subtitle = stringReference(value = "1 132,46 MATIC"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.PermissionRequired,
|
||||
percentLowerThenBest = PercentDifference.Value(value = 5f),
|
||||
)
|
||||
val contentStatePermissionIntegrated = contentState.copy(
|
||||
subtitle = stringReference(value = "1 132,46 MATIC"),
|
||||
percentLowerThenBest = PercentDifference.Value(value = 5f),
|
||||
approvalSettings = ProviderState.ApprovalSettings.Content({}),
|
||||
)
|
||||
val unavailableState = ProviderState.Unavailable(
|
||||
id = "1",
|
||||
name = "1inch",
|
||||
type = "DEX",
|
||||
iconUrl = "",
|
||||
alertText = stringReference(value = "Not available"),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
selectionType = ProviderState.SelectionType.NONE,
|
||||
onProviderClick = {},
|
||||
)
|
||||
val loadingState = ProviderState.Loading()
|
||||
|
|
@ -483,8 +530,11 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
|
|||
add(contentState to true)
|
||||
add(contentState to false)
|
||||
|
||||
add(contentState2 to true)
|
||||
add(contentState2 to false)
|
||||
add(contentStatePermissionRequired to true)
|
||||
add(contentStatePermissionRequired to false)
|
||||
|
||||
add(contentStatePermissionIntegrated to true)
|
||||
add(contentStatePermissionIntegrated to false)
|
||||
|
||||
add(unavailableState to true)
|
||||
add(unavailableState to false)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,9 @@ private fun SimpleProviderTrailing(state: ProviderState) {
|
|||
.size(TangemTheme.dimens.size20)
|
||||
.clip(RoundedCornerShape(TangemTheme.dimens.radius4)),
|
||||
)
|
||||
if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade) {
|
||||
if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade ||
|
||||
state.additionalBadge is ProviderState.AdditionalBadge.BestDexRate
|
||||
) {
|
||||
SimpleBestRateBadge(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
|
|
@ -173,6 +175,7 @@ private class SimpleProviderPreview : PreviewParameterProvider<ProviderState> {
|
|||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = {},
|
||||
),
|
||||
ProviderState.Content(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
|
||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
|
||||
|
|
@ -29,8 +33,11 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|||
import com.tangem.domain.models.wallet.isHotWallet
|
||||
import com.tangem.domain.swap.models.PredefinedPercentAmount
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.AssetRequirementsCondition
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.converters.SwapProviderResolver
|
||||
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
|
|
@ -38,12 +45,13 @@ import com.tangem.feature.swap.domain.models.domain.*
|
|||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.model.SwapNotificationsFactory
|
||||
import com.tangem.feature.swap.model.SwapProcessDataState
|
||||
import com.tangem.feature.swap.model.getLastLoadedSuccessStates
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.models.SwapButton.Mode
|
||||
import com.tangem.feature.swap.models.states.*
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.utils.formatToUIRepresentation
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.StringsSigns
|
||||
|
|
@ -70,6 +78,10 @@ internal class StateBuilder(
|
|||
) {
|
||||
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
|
||||
|
||||
private val amountScreenClickIntents by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SwapAmountScreenClickIntents(actions)
|
||||
}
|
||||
|
||||
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SwapNotificationsFactory(
|
||||
actions = actions,
|
||||
|
|
@ -80,6 +92,7 @@ internal class StateBuilder(
|
|||
|
||||
fun createInitialLoadingState(swapUIMode: SwapUIMode = SwapUIMode.Detailed): SwapStateHolder {
|
||||
return SwapStateHolder(
|
||||
titleId = R.string.common_swap,
|
||||
sendCardData = getEmptyCardState(
|
||||
isFromCard = true,
|
||||
emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY),
|
||||
|
|
@ -100,7 +113,7 @@ internal class StateBuilder(
|
|||
onChangeCardsClicked = actions.onChangeCardsClicked,
|
||||
onMaxAmountSelected = actions.onMaxAmountSelected,
|
||||
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
|
||||
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
|
||||
onShowPermissionBottomSheet = actions.onApproveClick,
|
||||
onSelectTokenClick = actions.onSelectTokenClick,
|
||||
onSuccess = actions.onSuccess,
|
||||
providerState = ProviderState.Empty(),
|
||||
|
|
@ -110,6 +123,7 @@ internal class StateBuilder(
|
|||
swapUIMode = swapUIMode,
|
||||
onSwapUIModeChange = actions.onSwapUIModeChange,
|
||||
onSwapTypeMenuOpened = actions.onSwapTypeMenuOpened,
|
||||
onTronBannerShown = actions.onTronBannerShown,
|
||||
shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled,
|
||||
)
|
||||
}
|
||||
|
|
@ -141,10 +155,15 @@ internal class StateBuilder(
|
|||
isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true,
|
||||
onClick = { },
|
||||
),
|
||||
shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency),
|
||||
predefinedButtons = createPredefinedButtons(
|
||||
shouldShowMaxAmount = shouldShowMaxAmount(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
emptyAmountState.isTransferMode,
|
||||
),
|
||||
predefinedButtons = createPredefinedButtons(
|
||||
fromToken = fromSwapCurrencyStatus?.currency,
|
||||
toCurrency = toSwapCurrencyStatus?.currency,
|
||||
isTransferMode = emptyAmountState.isTransferMode,
|
||||
),
|
||||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = ProviderState.Empty(),
|
||||
|
|
@ -194,9 +213,10 @@ internal class StateBuilder(
|
|||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
return uiStateHolder.copy(
|
||||
titleId = R.string.common_swap,
|
||||
sendCardData = uiStateHolder.sendCardData.copy(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
|
||||
|
|
@ -231,6 +251,11 @@ internal class StateBuilder(
|
|||
toSwapCurrencyStatus: SwapCurrencyStatus?,
|
||||
shouldResetAmount: Boolean,
|
||||
): SwapStateHolder {
|
||||
val shouldShowMaxAmount = shouldShowMaxAmount(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
emptyAmountState.isTransferMode,
|
||||
)
|
||||
return uiStateHolder.copy(
|
||||
sendCardData = uiStateHolder.sendCardData.updateCurrencyStatus(
|
||||
swapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
|
|
@ -254,10 +279,11 @@ internal class StateBuilder(
|
|||
isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true,
|
||||
onClick = { },
|
||||
),
|
||||
shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency),
|
||||
shouldShowMaxAmount = shouldShowMaxAmount,
|
||||
predefinedButtons = createPredefinedButtons(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
fromToken = fromSwapCurrencyStatus?.currency,
|
||||
toCurrency = toSwapCurrencyStatus?.currency,
|
||||
isTransferMode = emptyAmountState.isTransferMode,
|
||||
),
|
||||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = ProviderState.Empty(),
|
||||
|
|
@ -274,7 +300,7 @@ internal class StateBuilder(
|
|||
): SwapCardState {
|
||||
val cardType = if (isFromCard) {
|
||||
TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, true),
|
||||
|
|
@ -295,17 +321,17 @@ internal class StateBuilder(
|
|||
)
|
||||
} else if (shouldResetAmount) {
|
||||
copy(
|
||||
amountTextFieldValue = if (isFromCard) {
|
||||
null
|
||||
} else {
|
||||
TextFieldValue("0".appendApproximateSign())
|
||||
},
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
|
||||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
type = cardType,
|
||||
amountField = if (isFromCard) {
|
||||
emptyAmountField(swapCurrencyStatus)
|
||||
} else {
|
||||
displayAmountField("0".appendApproximateSign(), swapCurrencyStatus)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
copy(
|
||||
|
|
@ -314,10 +340,66 @@ internal class StateBuilder(
|
|||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
type = cardType,
|
||||
amountField = if (isFromCard) {
|
||||
if (amountField == null) {
|
||||
emptyAmountField(swapCurrencyStatus)
|
||||
} else {
|
||||
buildAmountField(
|
||||
amountRaw = amountField.cryptoAmount.value?.toPlainString().orEmpty(),
|
||||
fieldValue = amountField.value,
|
||||
isFiatValue = amountField.isFiatValue,
|
||||
fromSwapCurrencyStatus = swapCurrencyStatus,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
amountField
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyAmountField(fromSwapCurrencyStatus: SwapCurrencyStatus): AmountFieldModel = buildAmountField(
|
||||
amountRaw = "",
|
||||
fieldValue = "",
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds the read-only receive card [AmountFieldModel] from a display [value]. Reuses the existing
|
||||
* converter path; the read-only UI only reads [AmountFieldModel.value].
|
||||
*/
|
||||
private fun displayAmountField(value: String, status: SwapCurrencyStatus): AmountFieldModel = buildAmountField(
|
||||
amountRaw = "",
|
||||
fieldValue = value,
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = status,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds a status-free placeholder [AmountFieldModel] for the static [SwapCardState.Empty] card,
|
||||
* which has no [SwapCurrencyStatus]. The Empty card UI only reads [AmountFieldModel.value].
|
||||
*/
|
||||
private fun placeholderAmountField(value: String): AmountFieldModel = AmountFieldModel(
|
||||
value = value,
|
||||
onValueChange = {},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
|
||||
keyboardActions = KeyboardActions(),
|
||||
cryptoAmount = Amount(currencySymbol = "", value = BigDecimal.ZERO, decimals = 0),
|
||||
fiatAmount = Amount(currencySymbol = "", value = BigDecimal.ZERO, decimals = 0),
|
||||
isFiatValue = false,
|
||||
fiatValue = "",
|
||||
isFiatUnavailable = false,
|
||||
isValuePasted = false,
|
||||
onValuePastedTriggerDismiss = {},
|
||||
isError = false,
|
||||
isWarning = false,
|
||||
error = TextReference.EMPTY,
|
||||
)
|
||||
|
||||
private fun createCardState(
|
||||
swapCurrencyStatus: SwapCurrencyStatus?,
|
||||
emptyAmountState: SwapState.EmptyAmountState,
|
||||
|
|
@ -330,7 +412,7 @@ internal class StateBuilder(
|
|||
SwapCardState.SwapCardData(
|
||||
type = if (isFromCard) {
|
||||
TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, true),
|
||||
|
|
@ -342,16 +424,17 @@ internal class StateBuilder(
|
|||
accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, false),
|
||||
)
|
||||
},
|
||||
amountTextFieldValue = if (isFromCard) {
|
||||
null
|
||||
} else {
|
||||
TextFieldValue("0".appendApproximateSign())
|
||||
},
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
|
||||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
amountField = if (isFromCard) {
|
||||
emptyAmountField(swapCurrencyStatus)
|
||||
} else {
|
||||
displayAmountField("0".appendApproximateSign(), swapCurrencyStatus)
|
||||
},
|
||||
appCurrency = appCurrencyProvider(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -366,7 +449,7 @@ internal class StateBuilder(
|
|||
),
|
||||
),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(text = if (isFromCard) "0" else "0".appendApproximateSign()),
|
||||
amountField = placeholderAmountField(value = if (isFromCard) "0" else "0".appendApproximateSign()),
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
)
|
||||
|
||||
|
|
@ -374,6 +457,34 @@ internal class StateBuilder(
|
|||
uiStateHolder: SwapStateHolder,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
): SwapStateHolder = createBlockedSwapState(
|
||||
uiStateHolder = uiStateHolder,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
|
||||
)
|
||||
|
||||
fun createDestinationRequirementBlockedState(
|
||||
uiStateHolder: SwapStateHolder,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
requirement: AssetRequirementsCondition,
|
||||
onAssociateClick: () -> Unit,
|
||||
): SwapStateHolder = createBlockedSwapState(
|
||||
uiStateHolder = uiStateHolder,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
notifications = notificationsFactory.getDestinationRequirementNotifications(
|
||||
requirement = requirement,
|
||||
onAssociateClick = onAssociateClick,
|
||||
),
|
||||
)
|
||||
|
||||
private fun createBlockedSwapState(
|
||||
uiStateHolder: SwapStateHolder,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
notifications: ImmutableList<NotificationUM>,
|
||||
): SwapStateHolder {
|
||||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
return uiStateHolder.copy(
|
||||
|
|
@ -381,29 +492,27 @@ internal class StateBuilder(
|
|||
type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
text = "0",
|
||||
),
|
||||
amountField = displayAmountField(value = "0", status = fromSwapCurrencyStatus),
|
||||
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
|
||||
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
|
||||
balance = fromSwapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
appCurrency = appCurrencyProvider(),
|
||||
),
|
||||
receiveCardData = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
text = "0",
|
||||
),
|
||||
amountField = displayAmountField(value = "0", status = toSwapCurrencyStatus),
|
||||
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
|
||||
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
|
||||
balance = toSwapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
appCurrency = appCurrencyProvider(),
|
||||
),
|
||||
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
|
||||
notifications = notifications,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
isEnabled = false,
|
||||
|
|
@ -429,7 +538,7 @@ internal class StateBuilder(
|
|||
return uiStateHolder.copy(
|
||||
sendCardData = uiStateHolder.sendCardData.copy(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
|
||||
|
|
@ -440,7 +549,7 @@ internal class StateBuilder(
|
|||
type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
|
||||
),
|
||||
amountTextFieldValue = null,
|
||||
amountField = null,
|
||||
amountEquivalent = null,
|
||||
),
|
||||
notifications = persistentListOf(),
|
||||
|
|
@ -465,9 +574,7 @@ internal class StateBuilder(
|
|||
quoteModel: SwapState.QuotesLoadedState,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
swapProvider: SwapProvider,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
swapFee: SwapFee?,
|
||||
feeError: FeeSelectorUM.Error?,
|
||||
): SwapStateHolder {
|
||||
|
|
@ -512,12 +619,13 @@ internal class StateBuilder(
|
|||
return uiStateHolder.copy(
|
||||
sendCardData = SwapCardState.SwapCardData(
|
||||
type = sendInput,
|
||||
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
|
||||
amountEquivalent = uiStateHolder.sendCardData.amountEquivalent,
|
||||
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
|
||||
balance = fromSwapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
amountField = uiStateHolder.sendCardData.amountField,
|
||||
appCurrency = appCurrencyProvider(),
|
||||
),
|
||||
receiveCardData = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.ReadOnly(
|
||||
|
|
@ -525,10 +633,11 @@ internal class StateBuilder(
|
|||
onWarningClick = actions.onReceiveCardWarningClick,
|
||||
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
quoteModel.toTokenInfo.tokenAmount
|
||||
amountField = displayAmountField(
|
||||
value = quoteModel.toTokenInfo.tokenAmount
|
||||
.formatToUIRepresentation()
|
||||
.appendApproximateSign(),
|
||||
status = toSwapCurrencyStatus,
|
||||
),
|
||||
amountEquivalent = if (priceImpact.type.ordinal > PriceImpact.Type.LOW.ordinal) {
|
||||
combinedReference(
|
||||
|
|
@ -554,6 +663,7 @@ internal class StateBuilder(
|
|||
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
|
||||
balance = toSwapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
appCurrency = appCurrencyProvider(),
|
||||
),
|
||||
isInsufficientFunds = isInsufficientFundsCondition(quoteModel),
|
||||
notifications = notifications,
|
||||
|
|
@ -574,13 +684,9 @@ internal class StateBuilder(
|
|||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = swapProvider,
|
||||
fromTokenInfo = quoteModel.fromTokenInfo,
|
||||
toTokenInfo = quoteModel.toTokenInfo,
|
||||
permissionState = quoteModel.permissionState,
|
||||
state = quoteModel,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = actions.onProviderClick,
|
||||
),
|
||||
priceImpact = priceImpact,
|
||||
|
|
@ -618,7 +724,12 @@ internal class StateBuilder(
|
|||
)
|
||||
}
|
||||
|
||||
private fun shouldShowMaxAmount(fromToken: CryptoCurrency?, toCurrency: CryptoCurrency?): Boolean {
|
||||
private fun shouldShowMaxAmount(
|
||||
fromToken: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
isTransferMode: Boolean = false,
|
||||
): Boolean {
|
||||
if (isTransferMode) return true
|
||||
return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id)
|
||||
}
|
||||
|
||||
|
|
@ -631,9 +742,10 @@ internal class StateBuilder(
|
|||
private fun createPredefinedButtons(
|
||||
fromToken: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
isTransferMode: Boolean = false,
|
||||
): ImmutableList<PredefinedPercentButtonUM> {
|
||||
if (!swapFeatureToggles.isSwapPredefinedButtonsEnabled) return persistentListOf()
|
||||
val shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrency)
|
||||
val shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrency, isTransferMode)
|
||||
return PredefinedPercentAmount.entries
|
||||
.filter { it != PredefinedPercentAmount.MAX || shouldShowMaxAmount }
|
||||
.map { percent ->
|
||||
|
|
@ -702,7 +814,7 @@ internal class StateBuilder(
|
|||
toSwapCurrencyStatus: SwapCurrencyStatus?,
|
||||
balanceStatus: SwapBalanceStatus,
|
||||
expressDataError: ExpressDataError,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
swapFee: SwapFee?,
|
||||
): SwapStateHolder {
|
||||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
|
|
@ -722,7 +834,7 @@ internal class StateBuilder(
|
|||
expressDataError = expressDataError,
|
||||
onProviderClick = actions.onProviderClick,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
)
|
||||
val type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getCardAccountTitle(
|
||||
|
|
@ -733,19 +845,18 @@ internal class StateBuilder(
|
|||
val receiveCardData = toSwapCurrencyStatus?.status?.let { toToken ->
|
||||
SwapCardState.SwapCardData(
|
||||
type = type,
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
text = "0",
|
||||
),
|
||||
amountField = displayAmountField(value = "0", status = toSwapCurrencyStatus),
|
||||
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
|
||||
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
|
||||
balance = toToken.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
appCurrency = appCurrencyProvider(),
|
||||
)
|
||||
} ?: SwapCardState.Empty(
|
||||
type = type,
|
||||
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
|
||||
amountTextFieldValue = null,
|
||||
amountField = null,
|
||||
)
|
||||
return uiStateHolder.copy(
|
||||
receiveCardData = receiveCardData,
|
||||
|
|
@ -771,7 +882,7 @@ internal class StateBuilder(
|
|||
expressDataError: ExpressDataError,
|
||||
onProviderClick: (String) -> Unit,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
): ProviderState {
|
||||
return when (expressDataError) {
|
||||
is ExpressDataError.ExchangeTooSmallAmountError -> {
|
||||
|
|
@ -782,7 +893,7 @@ internal class StateBuilder(
|
|||
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
|
||||
),
|
||||
selectionType = selectionType,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -794,7 +905,7 @@ internal class StateBuilder(
|
|||
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
|
||||
),
|
||||
selectionType = selectionType,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -813,11 +924,10 @@ internal class StateBuilder(
|
|||
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
return uiStateHolder.copy(
|
||||
sendCardData = uiStateHolder.sendCardData.copy(
|
||||
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
),
|
||||
receiveCardData = uiStateHolder.receiveCardData.copy(
|
||||
amountTextFieldValue = TextFieldValue("0"),
|
||||
amountField = uiStateHolder.receiveCardData.amountField?.copy(value = "0"),
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
),
|
||||
notifications = persistentListOf(),
|
||||
|
|
@ -854,19 +964,58 @@ internal class StateBuilder(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the shared [AmountFieldModel] that carries the "from" card input state.
|
||||
*
|
||||
* @param amountRaw authoritative crypto amount (ungrouped) — drives [AmountFieldModel.cryptoAmount].
|
||||
* @param fieldValue value currently shown in the input field, expressed in the active currency.
|
||||
* @param isFiatValue whether the active input currency is fiat.
|
||||
*/
|
||||
private fun buildAmountField(
|
||||
amountRaw: String,
|
||||
fieldValue: String,
|
||||
isFiatValue: Boolean,
|
||||
isPastedAmount: Boolean,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
): AmountFieldModel {
|
||||
val appCurrency = appCurrencyProvider()
|
||||
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
|
||||
val cryptoDecimal = amountRaw.parseBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
val fiatValue = fiatRate?.multiply(cryptoDecimal).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
|
||||
return AmountFieldConverter(
|
||||
clickIntents = amountScreenClickIntents,
|
||||
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
|
||||
appCurrency = appCurrency,
|
||||
).convert(value = amountRaw).copy(
|
||||
value = fieldValue,
|
||||
isFiatValue = isFiatValue,
|
||||
fiatValue = fiatValue,
|
||||
isValuePasted = isPastedAmount,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun updateSwapAmount(
|
||||
uiState: SwapStateHolder,
|
||||
amountFormatted: String,
|
||||
amountRaw: String,
|
||||
fieldValue: String,
|
||||
isFiatValue: Boolean,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
minTxAmount: BigDecimal?,
|
||||
isPastedAmount: Boolean,
|
||||
): SwapStateHolder {
|
||||
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
|
||||
val amountToSend = amountRaw.parseBigDecimalOrNull()
|
||||
val currency = fromSwapCurrencyStatus.currency
|
||||
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
|
||||
val isFiatUnavailable = fiatRate == null
|
||||
val isFiatEffective = isFiatValue && !isFiatUnavailable
|
||||
val sendInput = if (minTxAmount != null && amountToSend != null && amountToSend < minTxAmount) {
|
||||
val minAmountFormatted = minTxAmount.format {
|
||||
crypto(cryptoCurrency = fromSwapCurrencyStatus.currency, ignoreSymbolPosition = true)
|
||||
crypto(cryptoCurrency = currency, ignoreSymbolPosition = true)
|
||||
}
|
||||
(uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy(
|
||||
inputError = TransactionCardType.InputError.WrongAmount,
|
||||
|
|
@ -880,17 +1029,22 @@ internal class StateBuilder(
|
|||
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
|
||||
) ?: uiState.sendCardData.type
|
||||
}
|
||||
// The secondary line shows the opposite currency: crypto when entering fiat, fiat otherwise.
|
||||
val amountEquivalent = if (isFiatEffective) {
|
||||
stringReference(amountToSend.orZero().format { crypto(currency) })
|
||||
} else {
|
||||
getFormattedFiatAmount(fiatRate?.let { amountToSend?.multiply(it).orZero() })
|
||||
}
|
||||
return uiState.copy(
|
||||
sendCardData = uiState.sendCardData.copy(
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
text = amountFormatted,
|
||||
selection = TextRange(amountFormatted.length),
|
||||
),
|
||||
amountEquivalent = getFormattedFiatAmount(
|
||||
fromSwapCurrencyStatus.status.value.fiatRate?.let { fiatRate ->
|
||||
amountToSend?.multiply(fiatRate).orZero()
|
||||
},
|
||||
amountField = buildAmountField(
|
||||
amountRaw = amountRaw,
|
||||
fieldValue = fieldValue,
|
||||
isFiatValue = isFiatEffective,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
isPastedAmount = isPastedAmount,
|
||||
),
|
||||
amountEquivalent = amountEquivalent,
|
||||
type = sendInput,
|
||||
),
|
||||
)
|
||||
|
|
@ -988,7 +1142,7 @@ internal class StateBuilder(
|
|||
toTokenFiatAmount = toFiatAmount,
|
||||
fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
|
||||
toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
|
||||
onExploreButtonClick = onExploreClick,
|
||||
navigationUM = swapSuccessNavigation(txUrl = txUrl, exploreClick = onExploreClick),
|
||||
onStatusButtonClick = onStatusClick,
|
||||
),
|
||||
)
|
||||
|
|
@ -1029,7 +1183,7 @@ internal class StateBuilder(
|
|||
toTokenFiatAmount = toFiatAmount,
|
||||
fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
|
||||
toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
|
||||
onExploreButtonClick = onExploreClick,
|
||||
navigationUM = swapSuccessNavigation(txUrl = txUrl, exploreClick = onExploreClick),
|
||||
onStatusButtonClick = {},
|
||||
),
|
||||
)
|
||||
|
|
@ -1068,18 +1222,24 @@ internal class StateBuilder(
|
|||
pricesLowerBest: Map<String, Float>,
|
||||
providersStates: Map<SwapProvider, SwapState>,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
): SwapStateHolder {
|
||||
val successStates = providersStates.getLastLoadedSuccessStates()
|
||||
val availableProvidersStates = providersStates.entries
|
||||
.mapNotNull { entry ->
|
||||
val additionalBadge = SwapProviderResolver.resolveBadge(
|
||||
states = successStates,
|
||||
provider = entry.key,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
state = entry.value,
|
||||
isSwapBestDexRateEnabled = isSwapBestDexRateEnabled,
|
||||
)
|
||||
entry.convertToProviderBottomSheetState(
|
||||
pricesLowerBest = pricesLowerBest,
|
||||
onProviderSelect = actions.onProviderSelect,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
bestRatedProviderId = bestRatedProviderId,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
onApprovalSelectClick = actions.onApproveTypeSelect,
|
||||
additionalBadge = additionalBadge,
|
||||
)
|
||||
}
|
||||
.sortedWith(ProviderPercentDiffComparator)
|
||||
|
|
@ -1183,24 +1343,21 @@ internal class StateBuilder(
|
|||
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(
|
||||
pricesLowerBest: Map<String, Float>,
|
||||
onProviderSelect: (String) -> Unit,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
onApprovalSelectClick: (SwapProvider) -> Unit,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
): ProviderState? {
|
||||
val provider = this.key
|
||||
return when (val state = this.value) {
|
||||
val (provider, state) = this
|
||||
return when (state) {
|
||||
is SwapState.EmptyAmountState, is SwapState.Transfer -> null
|
||||
is SwapState.QuotesLoadedState -> {
|
||||
SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = state.toTokenInfo,
|
||||
permissionState = state.permissionState,
|
||||
state = state,
|
||||
pricesLowerBest = pricesLowerBest,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
isBestRate = bestRatedProviderId == provider.providerId && !state.priceImpact.shouldShowWarning(),
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = onProviderSelect,
|
||||
onApprovalSelectClick = onApprovalSelectClick,
|
||||
)
|
||||
}
|
||||
is SwapState.SwapError -> getProviderStateForError(
|
||||
|
|
@ -1209,7 +1366,7 @@ internal class StateBuilder(
|
|||
expressDataError = state.error,
|
||||
onProviderClick = onProviderSelect,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1264,6 +1421,7 @@ internal class StateBuilder(
|
|||
return when (this) {
|
||||
is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon)
|
||||
is Account.Payment -> AccountIconUM.Payment
|
||||
is Account.Virtual -> AccountIconUM.Virtual
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
|
||||
/**
|
||||
* Adapter that exposes legacy swap [UiActions] through the shared [AmountScreenClickIntents] contract
|
||||
* so the from-card amount field can be constructed by the common `AmountFieldConverter`.
|
||||
*
|
||||
* Only the callbacks that the swap amount field actually wires are mapped to real actions; the rest
|
||||
* are no-ops, because swap-v1 overrides the corresponding [com.tangem.common.ui.amountScreen.models.AmountFieldModel]
|
||||
* fields (keyboardActions / onValuePastedTriggerDismiss) after conversion to preserve its existing behaviour.
|
||||
*/
|
||||
internal class SwapAmountScreenClickIntents(
|
||||
private val actions: UiActions,
|
||||
) : AmountScreenClickIntents {
|
||||
|
||||
override fun onAmountValueChange(value: String) = actions.onAmountChanged(value)
|
||||
|
||||
override fun onAmountPasteTriggerDismiss() = Unit
|
||||
|
||||
override fun onMaxValueClick() = actions.onMaxAmountSelected()
|
||||
|
||||
override fun onCurrencyChangeClick(isFiat: Boolean) = actions.onCurrencyChange(isFiat)
|
||||
|
||||
override fun onAmountNext() = Unit
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ private fun SwapTopBar(stateHolder: SwapStateHolder) {
|
|||
var shouldShowModeMenu by rememberSaveable { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
AppBarWithBackButtonAndIcon(
|
||||
text = stringResourceSafe(R.string.common_swap),
|
||||
text = stringResourceSafe(stateHolder.titleId),
|
||||
backIconRes = R.drawable.ic_close_24,
|
||||
iconRes = if (stateHolder.shouldShowAbMenu) R.drawable.ic_more_vertical_24 else null,
|
||||
onIconClick = if (stateHolder.shouldShowAbMenu) {
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
|
@ -50,6 +53,9 @@ import com.tangem.feature.swap.presentation.R
|
|||
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.receiveCard
|
||||
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.sendCard
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private const val KEYBOARD_AUTOHIDE_DELAY_MS = 5_000L
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
|
|
@ -88,7 +94,12 @@ internal fun SwapScreenContent(
|
|||
|
||||
feeBlock?.invoke(Modifier.fillMaxWidth())
|
||||
|
||||
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)
|
||||
if (state.notifications.isNotEmpty()) {
|
||||
SwapNotifications(
|
||||
notifications = state.notifications,
|
||||
onTronBannerShown = state.onTronBannerShown,
|
||||
)
|
||||
}
|
||||
|
||||
SpacerHMax()
|
||||
|
||||
|
|
@ -140,10 +151,30 @@ internal fun SwapScreenContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
AutohideKeyboardEffect(
|
||||
sendCardState = state.sendCardData,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AutohideKeyboardEffect(sendCardState: SwapCardState) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val swapCardData = sendCardState as? SwapCardState.SwapCardData
|
||||
|
||||
LaunchedEffect(swapCardData?.amountField?.value) {
|
||||
if (swapCardData == null) return@LaunchedEffect
|
||||
|
||||
delay(KEYBOARD_AUTOHIDE_DELAY_MS)
|
||||
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainInfo(state: SwapStateHolder) {
|
||||
ConstraintLayout(
|
||||
|
|
@ -316,7 +347,13 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) {
|
|||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
private fun SwapNotifications(notifications: List<NotificationUM>) {
|
||||
private fun SwapNotifications(notifications: List<NotificationUM>, onTronBannerShown: () -> Unit) {
|
||||
// The Tron token-fee banner's show-count is an "impression": tied to actual on-screen visibility.
|
||||
// LaunchedEffect re-arms only when the boolean flips, so it fires once per hidden -> shown appearance.
|
||||
val isTronBannerShown = notifications.any { it is SwapNotificationUM.Info.TronTokenFee }
|
||||
LaunchedEffect(isTronBannerShown) {
|
||||
if (isTronBannerShown) onTronBannerShown()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(color = TangemTheme.colors.background.secondary)
|
||||
|
|
@ -413,6 +450,7 @@ private fun getButtonTitle(mode: SwapButton.Mode): String {
|
|||
// region preview
|
||||
|
||||
private val state = SwapStateHolder(
|
||||
titleId = R.string.common_swap,
|
||||
sendCardData = sendCard,
|
||||
receiveCardData = receiveCard,
|
||||
notifications = persistentListOf(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import com.tangem.common.ui.navigationButtons.NavigationButton
|
||||
import com.tangem.common.ui.navigationButtons.NavigationUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
|
||||
/**
|
||||
* Builds the [NavigationUM] for the swap success screen, mirroring
|
||||
* `SendConfirmSuccessModel.configConfirmSuccessNavigation`.
|
||||
*
|
||||
* The explore button is always the single [NavigationUM.Content.primaryButton]; a transfer also
|
||||
* gets a share button, surfaced together as [NavigationUM.Content.secondaryPairButtonsUM] (the same
|
||||
* explore + share pair the send flow shows). [shareClick] is `null` for a regular swap, so the pair
|
||||
* is absent and only the explore button is rendered.
|
||||
*/
|
||||
internal fun swapSuccessNavigation(
|
||||
txUrl: String,
|
||||
exploreClick: () -> Unit,
|
||||
shareClick: (() -> Unit)? = null,
|
||||
): NavigationUM {
|
||||
val exploreButton = NavigationButton(
|
||||
textReference = resourceReference(R.string.common_explore),
|
||||
iconRes = R.drawable.ic_web_24,
|
||||
onClick = exploreClick,
|
||||
)
|
||||
val pairButtons = shareClick
|
||||
?.takeIf { txUrl.isNotEmpty() }
|
||||
?.let { onShare ->
|
||||
exploreButton to NavigationButton(
|
||||
textReference = resourceReference(R.string.common_share),
|
||||
iconRes = R.drawable.ic_share_24,
|
||||
onClick = onShare,
|
||||
)
|
||||
}
|
||||
|
||||
return NavigationUM.Content(
|
||||
source = SOURCE,
|
||||
title = TextReference.EMPTY,
|
||||
subtitle = null,
|
||||
backIconRes = R.drawable.ic_close_24,
|
||||
backIconClick = {},
|
||||
primaryButton = exploreButton,
|
||||
secondaryPairButtonsUM = pairButtons,
|
||||
)
|
||||
}
|
||||
|
||||
private const val SOURCE = "SwapSuccess"
|
||||
|
|
@ -14,6 +14,8 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.account.AccountTitle
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.navigationButtons.DoneButtons
|
||||
import com.tangem.common.ui.navigationButtons.NavigationUM
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
|
||||
|
|
@ -29,8 +31,8 @@ import com.tangem.core.ui.utils.toTimeFormat
|
|||
import com.tangem.feature.swap.models.SwapSuccessStateHolder
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.preview.SwapSuccessStatePreview
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.common.ui.FeeBlockSuccess
|
||||
|
||||
@Composable
|
||||
fun SwapSuccessScreen(state: SwapSuccessStateHolder, feeSelectorUM: FeeSelectorUM?, onBack: () -> Unit) {
|
||||
|
|
@ -49,10 +51,7 @@ fun SwapSuccessScreen(state: SwapSuccessStateHolder, feeSelectorUM: FeeSelectorU
|
|||
bottomBar = {
|
||||
SwapSuccessScreenButtons(
|
||||
textRes = R.string.common_close,
|
||||
txUrl = state.txUrl,
|
||||
shouldShowStatusButton = state.shouldShowStatusButton,
|
||||
onExploreClick = state.onExploreButtonClick,
|
||||
onStatusClick = state.onStatusButtonClick,
|
||||
state = state,
|
||||
onDoneClick = onBack,
|
||||
)
|
||||
},
|
||||
|
|
@ -170,40 +169,40 @@ private fun SwapAmountBlock(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun SwapSuccessScreenButtons(
|
||||
@StringRes textRes: Int,
|
||||
txUrl: String,
|
||||
shouldShowStatusButton: Boolean,
|
||||
onExploreClick: () -> Unit,
|
||||
onStatusClick: () -> Unit,
|
||||
onDoneClick: () -> Unit,
|
||||
) {
|
||||
private fun SwapSuccessScreenButtons(@StringRes textRes: Int, state: SwapSuccessStateHolder, onDoneClick: () -> Unit) {
|
||||
val content = state.navigationUM as? NavigationUM.Content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors.background.secondary)
|
||||
.padding(TangemTheme.dimens.spacing16),
|
||||
) {
|
||||
if (txUrl.isNotBlank()) {
|
||||
Row {
|
||||
SecondaryButtonIconStart(
|
||||
text = stringResourceSafe(id = R.string.common_explore),
|
||||
iconResId = R.drawable.ic_web_24,
|
||||
onClick = onExploreClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (shouldShowStatusButton) {
|
||||
SpacerW12()
|
||||
val pairButtons = content?.secondaryPairButtonsUM
|
||||
when {
|
||||
// Transfer mode: explore + share, the same pair the send success screen shows.
|
||||
pairButtons != null -> DoneButtons(pairButtons)
|
||||
// Regular swap: single explore button (+ provider status for CEX).
|
||||
content != null && state.txUrl.isNotBlank() -> {
|
||||
Row {
|
||||
val exploreButton = content.primaryButton
|
||||
SecondaryButtonIconStart(
|
||||
text = stringResourceSafe(id = R.string.express_provider),
|
||||
iconResId = R.drawable.ic_arrow_top_right_24,
|
||||
onClick = onStatusClick,
|
||||
text = exploreButton.textReference.resolveReference(),
|
||||
iconResId = requireNotNull(exploreButton.iconRes),
|
||||
onClick = exploreButton.onClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (state.shouldShowStatusButton) {
|
||||
SpacerW12()
|
||||
SecondaryButtonIconStart(
|
||||
text = stringResourceSafe(id = R.string.express_provider),
|
||||
iconResId = R.drawable.ic_arrow_top_right_24,
|
||||
onClick = state.onStatusButtonClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
SpacerH12()
|
||||
}
|
||||
SpacerH12()
|
||||
}
|
||||
PrimaryButton(
|
||||
text = stringResourceSafe(id = textRes),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.feature.swap.ui
|
|||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
|
|
@ -14,13 +15,16 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
|
|
@ -35,9 +39,14 @@ import com.tangem.core.ui.components.buttons.SmallButtonConfig
|
|||
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.fields.AmountTextField
|
||||
import com.tangem.core.ui.components.fields.AmountTextFieldColors
|
||||
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_16
|
||||
import com.tangem.core.ui.test.SwapTokenScreenTestTags
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.SwapCardState
|
||||
|
|
@ -108,9 +117,7 @@ private fun TransactionCardData(
|
|||
)
|
||||
|
||||
Content(
|
||||
type = cardState.type,
|
||||
amountEquivalent = cardState.amountEquivalent,
|
||||
textFieldValue = cardState.amountTextFieldValue,
|
||||
cardData = cardState,
|
||||
priceImpact = priceImpact,
|
||||
)
|
||||
}
|
||||
|
|
@ -175,7 +182,7 @@ private fun TransactionCardEmpty(
|
|||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = cardState.amountTextFieldValue?.text.orEmpty(),
|
||||
text = cardState.amountField?.value.orEmpty(),
|
||||
color = TangemTheme.colors.text.disabled,
|
||||
style = TangemTheme.typography.h2,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
|
|
@ -326,12 +333,9 @@ private fun Header(
|
|||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun Content(
|
||||
type: TransactionCardType,
|
||||
amountEquivalent: TextReference?,
|
||||
priceImpact: PriceImpact,
|
||||
textFieldValue: TextFieldValue?,
|
||||
) {
|
||||
private fun Content(cardData: SwapCardState.SwapCardData, priceImpact: PriceImpact) {
|
||||
val type = cardData.type
|
||||
val amountEquivalent = cardData.amountEquivalent
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
|
|
@ -352,9 +356,10 @@ private fun Content(
|
|||
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
|
||||
when (type) {
|
||||
is TransactionCardType.ReadOnly -> {
|
||||
if (textFieldValue != null) {
|
||||
val value = cardData.amountField?.value
|
||||
if (value != null) {
|
||||
Text(
|
||||
text = textFieldValue.text,
|
||||
text = value,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.h2,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
|
|
@ -374,77 +379,25 @@ private fun Content(
|
|||
}
|
||||
}
|
||||
is TransactionCardType.Inputtable -> {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
AutoSizeTextField(
|
||||
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
|
||||
focusRequester = focusRequester,
|
||||
textFieldValue = textFieldValue ?: TextFieldValue(),
|
||||
isEnabled = type.isEnabled,
|
||||
onAmountChange = { type.onAmountChanged(it) },
|
||||
onFocusChange = type.onFocusChanged,
|
||||
)
|
||||
|
||||
LaunchedEffect(type.isEnabled) {
|
||||
if (type.isEnabled) {
|
||||
focusRequester.requestFocus()
|
||||
} else {
|
||||
focusRequester.freeFocus()
|
||||
}
|
||||
}
|
||||
AmountInputField(cardData = cardData, type = type, modifier = sumTextModifier)
|
||||
}
|
||||
}
|
||||
|
||||
SpacerH4()
|
||||
|
||||
if (amountEquivalent != null) {
|
||||
if (type is TransactionCardType.ReadOnly) {
|
||||
Row(
|
||||
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
|
||||
Text(
|
||||
text = amount.resolveAnnotatedReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
if (type.shouldShowWarning) {
|
||||
SpacerW4()
|
||||
IconButton(
|
||||
onClick = {
|
||||
type.onWarningClick?.invoke()
|
||||
},
|
||||
modifier = Modifier.size(size = TangemTheme.dimens.size20),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_information_24),
|
||||
contentDescription = null,
|
||||
tint = when (priceImpact.type) {
|
||||
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
|
||||
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
|
||||
else -> TangemTheme.colors.text.tertiary
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
|
||||
Text(
|
||||
text = amount.resolveAnnotatedReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
|
||||
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
when (type) {
|
||||
is TransactionCardType.ReadOnly -> ReceiveAmountEquivalent(
|
||||
amountEquivalent = amountEquivalent,
|
||||
type = type,
|
||||
priceImpact = priceImpact,
|
||||
)
|
||||
is TransactionCardType.Inputtable -> SwapAmountEquivalent(
|
||||
amountEquivalent = amountEquivalent,
|
||||
isFiatValue = cardData.amountField?.isFiatValue == true,
|
||||
isFiatUnavailable = cardData.amountField?.isFiatUnavailable == true,
|
||||
onCurrencyChange = type.onCurrencyChange,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RectangleShimmer(
|
||||
|
|
@ -460,6 +413,151 @@ private fun Content(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AmountInputField(
|
||||
cardData: SwapCardState.SwapCardData,
|
||||
type: TransactionCardType.Inputtable,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val amountField = cardData.amountField ?: return
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val activeAmount = if (amountField.isFiatValue) {
|
||||
amountField.fiatAmount
|
||||
} else {
|
||||
amountField.cryptoAmount
|
||||
}
|
||||
|
||||
AmountTextField(
|
||||
value = amountField.value,
|
||||
decimals = activeAmount.decimals,
|
||||
onValueChange = amountField.onValueChange,
|
||||
textStyle = TangemTheme.typography.h2.copy(color = TangemTheme.colors.text.primary1),
|
||||
isEnabled = type.isEnabled,
|
||||
isAutoResize = true,
|
||||
visualTransformation = AmountVisualTransformation(
|
||||
currencyCode = cardData.appCurrency.code.takeIf { amountField.isFiatValue },
|
||||
symbol = activeAmount.currencySymbol.takeIf { amountField.isFiatValue },
|
||||
decimals = activeAmount.decimals,
|
||||
symbolColor = TangemTheme.colors.text.disabled,
|
||||
),
|
||||
colors = AmountTextFieldColors(
|
||||
textColor = TangemTheme.colors.text.primary1,
|
||||
disabledTextColor = TangemTheme.colors.text.disabled,
|
||||
backgroundColor = TangemTheme.colors.background.primary,
|
||||
),
|
||||
isValuePasted = amountField.isValuePasted,
|
||||
onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss,
|
||||
keyboardOptions = amountField.keyboardOptions,
|
||||
keyboardActions = amountField.keyboardActions,
|
||||
modifier = modifier
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { type.onFocusChanged(it.hasFocus) }
|
||||
.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
|
||||
)
|
||||
|
||||
LaunchedEffect(type.isEnabled) {
|
||||
if (type.isEnabled) {
|
||||
focusRequester.requestFocus()
|
||||
} else {
|
||||
focusRequester.freeFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiveAmountEquivalent(
|
||||
amountEquivalent: TextReference,
|
||||
type: TransactionCardType.ReadOnly,
|
||||
priceImpact: PriceImpact,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
|
||||
Text(
|
||||
text = amount.resolveAnnotatedReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
if (type.shouldShowWarning) {
|
||||
SpacerW4()
|
||||
IconButton(
|
||||
onClick = { type.onWarningClick?.invoke() },
|
||||
modifier = Modifier.size(size = TangemTheme.dimens.size20),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_information_24),
|
||||
contentDescription = null,
|
||||
tint = when (priceImpact.type) {
|
||||
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
|
||||
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
|
||||
else -> TangemTheme.colors.text.tertiary
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CURRENCY_TOGGLE_ROTATED_DEGREE = 180f
|
||||
private const val CURRENCY_TOGGLE_INITIAL_DEGREE = 0f
|
||||
|
||||
@Composable
|
||||
private fun SwapAmountEquivalent(
|
||||
amountEquivalent: TextReference,
|
||||
isFiatValue: Boolean,
|
||||
isFiatUnavailable: Boolean,
|
||||
onCurrencyChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val rowModifier = Modifier
|
||||
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
|
||||
.then(
|
||||
if (isFiatUnavailable) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { onCurrencyChange(!isFiatValue) },
|
||||
)
|
||||
},
|
||||
)
|
||||
Row(
|
||||
modifier = rowModifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
|
||||
) {
|
||||
if (!isFiatUnavailable) {
|
||||
val iconRotation by animateFloatAsState(
|
||||
targetValue = if (isFiatValue) CURRENCY_TOGGLE_ROTATED_DEGREE else CURRENCY_TOGGLE_INITIAL_DEGREE,
|
||||
label = "Currency toggle icon rotation",
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.ic_arrow_swap_horizontal_16,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size16)
|
||||
.graphicsLayer { rotationZ = iconRotation },
|
||||
)
|
||||
}
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
|
||||
Text(
|
||||
text = amount.resolveAnnotatedReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
fun Token(currencyIconState: CurrencyIconState, tokenSymbol: TextReference) {
|
||||
|
|
|
|||
|
|
@ -13,14 +13,11 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
|
@ -99,8 +96,7 @@ private fun SimpleTransactionCardData(
|
|||
)
|
||||
|
||||
SimpleContent(
|
||||
type = cardState.type,
|
||||
textFieldValue = cardState.amountTextFieldValue,
|
||||
cardData = cardState,
|
||||
priceImpact = priceImpact,
|
||||
)
|
||||
}
|
||||
|
|
@ -165,7 +161,7 @@ private fun SimpleTransactionCardEmpty(
|
|||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = cardState.amountTextFieldValue?.text.orEmpty(),
|
||||
text = cardState.amountField?.value.orEmpty(),
|
||||
color = TangemTheme.colors.text.disabled,
|
||||
style = TangemTheme.typography.h2,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
|
|
@ -181,7 +177,7 @@ private fun SimpleTransactionCardEmpty(
|
|||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
|
||||
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
|
||||
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
SecondarySmallButton(
|
||||
|
|
@ -243,7 +239,7 @@ private fun SimpleTransactionCardLoading(modifier: Modifier = Modifier) {
|
|||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = 20.dp, minWidth = 40.dp)
|
||||
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
|
||||
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
SecondarySmallButton(
|
||||
|
|
@ -308,7 +304,8 @@ private fun SimpleHeader(
|
|||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, textFieldValue: TextFieldValue?) {
|
||||
private fun SimpleContent(cardData: SwapCardState.SwapCardData, priceImpact: PriceImpact) {
|
||||
val type = cardData.type
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
|
|
@ -326,9 +323,10 @@ private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, t
|
|||
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
|
||||
when (type) {
|
||||
is TransactionCardType.ReadOnly -> {
|
||||
if (textFieldValue != null) {
|
||||
val value = cardData.amountField?.value
|
||||
if (value != null) {
|
||||
Text(
|
||||
text = textFieldValue.text,
|
||||
text = value,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.h2,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
|
|
@ -348,16 +346,7 @@ private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, t
|
|||
}
|
||||
}
|
||||
is TransactionCardType.Inputtable -> {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
AutoSizeTextField(
|
||||
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
|
||||
focusRequester = focusRequester,
|
||||
textFieldValue = textFieldValue ?: TextFieldValue(),
|
||||
isEnabled = type.isEnabled,
|
||||
onAmountChange = { type.onAmountChanged(it) },
|
||||
onFocusChange = type.onFocusChanged,
|
||||
)
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
AmountInputField(cardData = cardData, type = type, modifier = sumTextModifier)
|
||||
}
|
||||
}
|
||||
SpacerH4()
|
||||
|
|
|
|||
|
|
@ -1,22 +1,30 @@
|
|||
package com.tangem.feature.swap.ui.preview
|
||||
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
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.common.ui.account.AccountNameUM
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.tokens.model.AmountType
|
||||
import com.tangem.feature.swap.models.SwapCardState
|
||||
import com.tangem.feature.swap.models.TransactionCardType
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal object SwapTransactionCardPreview {
|
||||
|
||||
val sendCard = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Account(
|
||||
|
|
@ -26,12 +34,33 @@ internal object SwapTransactionCardPreview {
|
|||
),
|
||||
isEnabled = true,
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = stringReference("1 000 000"),
|
||||
currencyIconState = CurrencyIconState.Loading,
|
||||
tokenSymbol = stringReference("DAI"),
|
||||
balance = stringReference("Balance: 123123123.123123 DAI"),
|
||||
isBalanceHidden = false,
|
||||
appCurrency = AppCurrency.Default,
|
||||
amountField = AmountFieldModel(
|
||||
value = "100",
|
||||
onValueChange = {},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
|
||||
keyboardActions = KeyboardActions(),
|
||||
cryptoAmount = Amount(currencySymbol = "DAI", value = BigDecimal("100"), decimals = 18),
|
||||
fiatAmount = Amount(
|
||||
currencySymbol = "$",
|
||||
value = BigDecimal("100"),
|
||||
decimals = 2,
|
||||
type = AmountType.FiatType("USD"),
|
||||
),
|
||||
isFiatValue = false,
|
||||
fiatValue = "$100.00",
|
||||
isFiatUnavailable = false,
|
||||
isValuePasted = false,
|
||||
onValuePastedTriggerDismiss = {},
|
||||
isError = false,
|
||||
isWarning = false,
|
||||
error = TextReference.EMPTY,
|
||||
),
|
||||
)
|
||||
|
||||
val receiveCard = SwapCardState.SwapCardData(
|
||||
|
|
@ -42,12 +71,33 @@ internal object SwapTransactionCardPreview {
|
|||
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
|
||||
),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = stringReference("1 000 000"),
|
||||
currencyIconState = CurrencyIconState.Loading,
|
||||
tokenSymbol = stringReference("DAI"),
|
||||
balance = stringReference("Balance: 33333 DAI"),
|
||||
isBalanceHidden = false,
|
||||
appCurrency = AppCurrency.Default,
|
||||
amountField = AmountFieldModel(
|
||||
value = "100",
|
||||
onValueChange = {},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
|
||||
keyboardActions = KeyboardActions(),
|
||||
cryptoAmount = Amount(currencySymbol = "DAI", value = BigDecimal("100"), decimals = 18),
|
||||
fiatAmount = Amount(
|
||||
currencySymbol = "$",
|
||||
value = BigDecimal("100"),
|
||||
decimals = 2,
|
||||
type = AmountType.FiatType("USD"),
|
||||
),
|
||||
isFiatValue = false,
|
||||
fiatValue = "$100.00",
|
||||
isFiatUnavailable = false,
|
||||
isValuePasted = false,
|
||||
onValuePastedTriggerDismiss = {},
|
||||
isError = false,
|
||||
isWarning = false,
|
||||
error = TextReference.EMPTY,
|
||||
),
|
||||
)
|
||||
|
||||
val emptyReadOnlyCard = SwapCardState.Empty(
|
||||
|
|
@ -55,24 +105,22 @@ internal object SwapTransactionCardPreview {
|
|||
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
|
||||
),
|
||||
amountEquivalent = stringReference("$0.00"),
|
||||
amountTextFieldValue = null,
|
||||
amountField = null,
|
||||
)
|
||||
|
||||
val emptyInputtableCard = SwapCardState.Empty(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)),
|
||||
isEnabled = false,
|
||||
),
|
||||
amountEquivalent = stringReference("$0.00"),
|
||||
amountTextFieldValue = null,
|
||||
amountField = null,
|
||||
)
|
||||
|
||||
val loadingCard = SwapCardState.Loading(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
package com.tangem.feature.swap.ui.transfer
|
||||
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
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.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.ui.utils.parseBigDecimal
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isTezos
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isTron
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -24,42 +33,57 @@ import javax.inject.Inject
|
|||
|
||||
internal class SwapTransferNotificationsFactory @Inject constructor() {
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun getNotifications(
|
||||
transferState: SwapState.Transfer,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
fee: Fee?,
|
||||
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
|
||||
onReduceToAmount: (SwapAmount) -> Unit,
|
||||
actions: UiActions,
|
||||
): ImmutableList<NotificationUM> {
|
||||
// The fee selector exposes a single sealed state; narrow it here so call sites pass the raw
|
||||
// FeeSelectorUM and this factory owns the Content/Error/Loading discrimination.
|
||||
val feeContent = feeSelectorUM
|
||||
val getFeeError = (feeSelectorUM as? FeeSelectorUM.Error)?.error
|
||||
return buildList {
|
||||
maybeAddRentExemptionError(transferState)
|
||||
addRentExemptionNotification(transferState.currencyCheck?.rentWarning)
|
||||
maybeAddDomainWarnings(
|
||||
state = transferState,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
fee = fee,
|
||||
onReduceByAmount = onReduceByAmount,
|
||||
onReduceToAmount = onReduceToAmount,
|
||||
feeSelectorUM = feeContent,
|
||||
onReduceByAmount = actions.onReduceByAmount,
|
||||
onReduceToAmount = actions.onReduceToAmount,
|
||||
)
|
||||
maybeAddNeedReserveToCreateAccountWarning(transferState)
|
||||
maybeAddExceedsBalanceNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = feeContent,
|
||||
onBuyClick = actions.openTokenDetailsScreen,
|
||||
)
|
||||
maybeAddTooHighOrTooLowNotification(feeContent)
|
||||
addTronNetworkFeesNotification(
|
||||
cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
|
||||
transferState = transferState,
|
||||
)
|
||||
maybeAddFeeUnreachableNotification(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
feeError = getFeeError,
|
||||
actions = actions,
|
||||
)
|
||||
}.toPersistentList()
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddRentExemptionError(state: SwapState.Transfer) {
|
||||
state.currencyCheck?.rentWarning?.let {
|
||||
add(NotificationUM.Solana.RentInfo(it))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
|
||||
state: SwapState.Transfer,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
fee: Fee?,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
|
||||
onReduceToAmount: (SwapAmount) -> Unit,
|
||||
) {
|
||||
val swapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus
|
||||
val amount = state.fromTokenInfo.tokenAmount
|
||||
val balance = swapCurrencyStatus.status.value.amount ?: BigDecimal.ZERO
|
||||
val fee = (feeSelectorUM as? FeeSelectorUM.Content)?.selectedFeeItem?.fee
|
||||
val feeValue = fee?.amount?.value.orZero()
|
||||
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
|
||||
addExistentialWarningNotification(
|
||||
|
|
@ -97,7 +121,8 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
|
|||
sendingAmount = amount.value,
|
||||
cryptoCurrency = swapCurrencyStatus.currency,
|
||||
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
|
||||
isAccountFunded = true,
|
||||
isAccountFunded = state.currencyCheck?.isAccountFunded == true,
|
||||
hasRequiredTrustline = state.hasRequiredTrustline,
|
||||
)
|
||||
addReduceAmountNotification(
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
|
|
@ -172,4 +197,78 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddExceedsBalanceNotifications(
|
||||
transferState: SwapState.Transfer,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
onBuyClick: (CryptoCurrency) -> Unit,
|
||||
) {
|
||||
val cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status
|
||||
addExceedsBalanceNotification(
|
||||
cryptoCurrencyWarning = transferState.cryptoCurrencyWarning,
|
||||
cryptoCurrencyStatus = cryptoCurrencyStatus,
|
||||
shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(
|
||||
networkId = cryptoCurrencyStatus.currency.network.rawId,
|
||||
),
|
||||
onClick = onBuyClick,
|
||||
onAnalyticsEvent = {},
|
||||
onResetAnalyticsEvent = {},
|
||||
)
|
||||
val feeAmount = (feeSelectorUM as? FeeSelectorUM.Content)?.selectedFeeItem?.fee?.amount?.value
|
||||
if (feeAmount != null) {
|
||||
addExceedBalanceNotification(
|
||||
feeAmount = feeAmount,
|
||||
sendingAmount = transferState.sendingAmount,
|
||||
isSubtractionAvailable = transferState.isAmountSubtractAvailable,
|
||||
cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CanBeNonNullable")
|
||||
private fun MutableList<NotificationUM>.maybeAddTooHighOrTooLowNotification(feeSelectorUM: FeeSelectorUM?) {
|
||||
val content = feeSelectorUM as? FeeSelectorUM.Content ?: return
|
||||
val (isFeeTooHigh, diff) = FeeCalculationUtils.checkIfCustomFeeTooHigh(feeSelectorUM = content)
|
||||
if (isFeeTooHigh) {
|
||||
add(NotificationUM.Warning.TooHigh(diff))
|
||||
}
|
||||
if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM = content)) {
|
||||
add(NotificationUM.Warning.FeeTooLow)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.addTronNetworkFeesNotification(
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
transferState: SwapState.Transfer,
|
||||
) {
|
||||
val cryptoCurrency = cryptoCurrencyStatus.currency
|
||||
val isTronToken = cryptoCurrency is CryptoCurrency.Token && isTron(cryptoCurrency.network.rawId)
|
||||
val isVisible = isTronToken &&
|
||||
transferState.tronFeeNotificationShowCount <= TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT
|
||||
|
||||
if (isVisible) {
|
||||
add(SwapNotificationUM.Info.TronTokenFee)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddFeeUnreachableNotification(
|
||||
transferState: SwapState.Transfer,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
feeError: GetFeeError?,
|
||||
actions: UiActions,
|
||||
) {
|
||||
feeCryptoCurrencyStatus ?: return
|
||||
addFeeUnreachableNotification(
|
||||
tokenStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
|
||||
coinStatus = feeCryptoCurrencyStatus,
|
||||
feeError = feeError,
|
||||
dustValue = transferState.currencyCheck?.dustValue,
|
||||
onReload = actions.onRetryClick,
|
||||
onClick = actions.openTokenDetailsScreen,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
package com.tangem.feature.swap.ui.transfer
|
||||
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
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.account.AccountIconUM
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
|
||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
|
||||
|
|
@ -16,6 +21,7 @@ import com.tangem.core.ui.extensions.wrappedList
|
|||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.simple
|
||||
import com.tangem.core.ui.utils.parseBigDecimalOrNull
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
|
|
@ -29,8 +35,12 @@ import com.tangem.feature.swap.models.*
|
|||
import com.tangem.feature.swap.models.SwapButton.Mode
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.features.send.v2.api.utils.formatFooterFiatFee
|
||||
import com.tangem.features.send.v2.api.utils.getTronTokenFeeSendingText
|
||||
import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents
|
||||
import com.tangem.feature.swap.ui.swapSuccessNavigation
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.utils.formatFooterFiatFee
|
||||
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import java.math.BigDecimal
|
||||
import javax.inject.Inject
|
||||
|
|
@ -43,45 +53,35 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
|
||||
private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun createTransferState(
|
||||
actions: UiActions,
|
||||
transferState: SwapState.Transfer,
|
||||
uiStateHolder: SwapStateHolder,
|
||||
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
fee: Fee?,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
): SwapStateHolder {
|
||||
val fromTokenSwapInfo = transferState.fromTokenInfo
|
||||
val toTokenSwapInfo = transferState.toTokenInfo
|
||||
val isInsufficientBalance = transferState.isInsufficientBalance
|
||||
val amountTextFieldValue = (uiStateHolder.sendCardData as? SwapCardState.SwapCardData)?.amountTextFieldValue
|
||||
val prevSendCard = uiStateHolder.sendCardData as? SwapCardState.SwapCardData
|
||||
val prevAmountField = prevSendCard?.amountField
|
||||
val notifications = notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = feeSelectorUM,
|
||||
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
|
||||
fee = fee,
|
||||
onReduceByAmount = actions.onReduceByAmount,
|
||||
onReduceToAmount = actions.onReduceToAmount,
|
||||
actions = actions,
|
||||
)
|
||||
return uiStateHolder.copy(
|
||||
sendCardData = createSendSwapCardState(
|
||||
actions = actions,
|
||||
amountTextFieldValue = amountTextFieldValue,
|
||||
tokenSwapInfo = fromTokenSwapInfo,
|
||||
appCurrency = transferState.appCurrency,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = true,
|
||||
isBalanceHidden = transferState.isBalanceHidden,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
),
|
||||
receiveCardData = createSendSwapCardState(
|
||||
actions = actions,
|
||||
amountTextFieldValue = amountTextFieldValue,
|
||||
tokenSwapInfo = toTokenSwapInfo,
|
||||
appCurrency = transferState.appCurrency,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
isBalanceHidden = transferState.isBalanceHidden,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
prevAmountField = prevAmountField,
|
||||
),
|
||||
receiveCardData = createReceiveCard(actions = actions, transferState = transferState),
|
||||
isInsufficientFunds = isInsufficientBalance,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(transferState.userWallet),
|
||||
|
|
@ -97,35 +97,153 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
@Suppress("LongParameterList")
|
||||
private fun createSendSwapCardState(
|
||||
actions: UiActions,
|
||||
amountTextFieldValue: TextFieldValue?,
|
||||
tokenSwapInfo: TokenSwapInfo,
|
||||
appCurrency: AppCurrency,
|
||||
isAccountsMode: Boolean,
|
||||
isFromCard: Boolean,
|
||||
isBalanceHidden: Boolean,
|
||||
isInsufficientBalance: Boolean,
|
||||
prevAmountField: AmountFieldModel?,
|
||||
): SwapCardState {
|
||||
val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus
|
||||
val currency = swapCurrencyStatus.currency
|
||||
|
||||
return SwapCardState.SwapCardData(
|
||||
type = createSendTransactionCardType(
|
||||
actions = actions,
|
||||
swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isFromCard = isFromCard,
|
||||
isFromCard = true,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
),
|
||||
currencyIconState = iconConverter.convert(
|
||||
value = swapCurrencyStatus.status,
|
||||
),
|
||||
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
|
||||
tokenSymbol = stringReference(currency.symbol),
|
||||
amountEquivalent = getFormattedFiatAmount(
|
||||
appCurrency = appCurrency,
|
||||
amount = tokenSwapInfo.amountFiat,
|
||||
),
|
||||
amountTextFieldValue = amountTextFieldValue,
|
||||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
appCurrency = appCurrency,
|
||||
amountField = buildAmountField(
|
||||
actions = actions,
|
||||
prevAmountField = prevAmountField,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
appCurrency = appCurrency,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the read-only receive card from [SwapState.Transfer.sendingAmount] — the amount that will
|
||||
* actually be received, already reduced by the fee when fee coverage applies. While the reduced amount
|
||||
* is not yet known (fee still loading) the amount and fiat fields are null, which makes the read-only
|
||||
* card render a shimmer instead of the un-subtracted value.
|
||||
*/
|
||||
private fun createReceiveCard(actions: UiActions, transferState: SwapState.Transfer): SwapCardState {
|
||||
val toTokenSwapInfo = transferState.toTokenInfo
|
||||
val swapCurrencyStatus = toTokenSwapInfo.swapCurrencyStatus
|
||||
val currency = swapCurrencyStatus.currency
|
||||
val appCurrency = transferState.appCurrency
|
||||
val sendingAmount = transferState.sendingAmount
|
||||
// No reduction can happen when the balance is insufficient (fee coverage requires balance >= amount),
|
||||
// so there is nothing to wait for — show the amount instead of a shimmer.
|
||||
val isLoading = transferState.isSendingAmountLoading && !transferState.isInsufficientBalance
|
||||
val fiatRate = swapCurrencyStatus.status.value.fiatRate
|
||||
|
||||
return SwapCardState.SwapCardData(
|
||||
type = createSendTransactionCardType(
|
||||
actions = actions,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
isInsufficientBalance = transferState.isInsufficientBalance,
|
||||
),
|
||||
currencyIconState = iconConverter.convert(
|
||||
value = swapCurrencyStatus.status,
|
||||
),
|
||||
tokenSymbol = stringReference(currency.symbol),
|
||||
amountEquivalent = if (isLoading) {
|
||||
null
|
||||
} else {
|
||||
getFormattedFiatAmount(appCurrency = appCurrency, amount = fiatRate?.multiply(sendingAmount))
|
||||
},
|
||||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = transferState.isBalanceHidden,
|
||||
appCurrency = appCurrency,
|
||||
amountField = if (isLoading) {
|
||||
null
|
||||
} else {
|
||||
val value = sendingAmount.format {
|
||||
simple(decimals = currency.decimals)
|
||||
}
|
||||
displayAmountField(
|
||||
actions = actions,
|
||||
value = value,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
appCurrency = appCurrency,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the read-only receive card [AmountFieldModel] in transfer mode from a display [value].
|
||||
* The read-only UI only reads [AmountFieldModel.value].
|
||||
*/
|
||||
private fun displayAmountField(
|
||||
actions: UiActions,
|
||||
value: String,
|
||||
swapCurrencyStatus: SwapCurrencyStatus,
|
||||
appCurrency: AppCurrency,
|
||||
): AmountFieldModel = AmountFieldConverter(
|
||||
clickIntents = SwapAmountScreenClickIntents(actions),
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
appCurrency = appCurrency,
|
||||
).convert(value = "").copy(
|
||||
value = value,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
keyboardActions = KeyboardActions(),
|
||||
onValuePastedTriggerDismiss = {},
|
||||
)
|
||||
|
||||
/**
|
||||
* Rebuilds the "from" card [AmountFieldModel] in transfer mode, preserving the previously entered
|
||||
* value and the crypto/fiat toggle while refreshing currency-derived fields against the latest status.
|
||||
*/
|
||||
private fun buildAmountField(
|
||||
actions: UiActions,
|
||||
prevAmountField: AmountFieldModel?,
|
||||
swapCurrencyStatus: SwapCurrencyStatus,
|
||||
appCurrency: AppCurrency,
|
||||
): AmountFieldModel {
|
||||
val fiatRate = swapCurrencyStatus.status.value.fiatRate
|
||||
val isFiatValue = prevAmountField?.isFiatValue == true && fiatRate != null
|
||||
val cryptoDecimal = prevAmountField?.cryptoAmount?.value.orZero()
|
||||
val fiatDecimal = fiatRate?.multiply(cryptoDecimal)
|
||||
// The converter is the single source for cryptoAmount / fiatAmount construction (FIAT_DECIMALS = 2).
|
||||
// The previously entered value + crypto/fiat toggle display are restored afterwards via copy(...),
|
||||
// keeping the resulting AmountFieldModel field-for-field equivalent to the prior hand-rolled builder.
|
||||
return AmountFieldConverter(
|
||||
clickIntents = SwapAmountScreenClickIntents(actions),
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
appCurrency = appCurrency,
|
||||
).convert(value = cryptoDecimal.toPlainString()).copy(
|
||||
value = prevAmountField?.value.orEmpty(),
|
||||
isFiatValue = isFiatValue,
|
||||
fiatValue = fiatDecimal?.format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}.orEmpty(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
keyboardActions = KeyboardActions(),
|
||||
onValuePastedTriggerDismiss = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +265,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
)
|
||||
}
|
||||
TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = if (isInsufficientBalance) {
|
||||
TransactionCardType.InputError.InsufficientFunds
|
||||
|
|
@ -208,9 +326,13 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
return when (this) {
|
||||
is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon)
|
||||
is Account.Payment -> AccountIconUM.Payment
|
||||
is Account.Virtual -> AccountIconUM.Virtual
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [isTangemPayWithdrawal] - if true - Tangem pay withdrawal done with no fee, skip fee nullability check
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun updateTransferButtonEnableState(
|
||||
dataState: SwapProcessDataState,
|
||||
|
|
@ -219,30 +341,41 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
uiStateHolder: SwapStateHolder,
|
||||
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
fee: Fee?,
|
||||
isTangemPayWithdrawal: Boolean,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
): SwapStateHolder {
|
||||
val notifications = notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
|
||||
fee = fee,
|
||||
onReduceByAmount = actions.onReduceByAmount,
|
||||
onReduceToAmount = actions.onReduceToAmount,
|
||||
feeSelectorUM = feeSelectorUM,
|
||||
actions = actions,
|
||||
)
|
||||
return uiStateHolder.copy(
|
||||
notifications = notifications,
|
||||
// Rebuild the receive card from the refreshed transferState: this path runs after the fee
|
||||
// selector resolves, when sendingAmount may have just been reduced by the fee. Only the
|
||||
// receive card is rebuilt to avoid clobbering the user's in-progress input on the "from" card.
|
||||
receiveCardData = createReceiveCard(actions = actions, transferState = transferState),
|
||||
swapButton = uiStateHolder.swapButton.copy(
|
||||
isEnabled = getTransferButtonEnabled(notifications, fee),
|
||||
isEnabled = getTransferButtonEnabled(notifications, fee, isTangemPayWithdrawal),
|
||||
),
|
||||
transferFooter = getSendingFooterText(
|
||||
dataState = dataState,
|
||||
fee = fee,
|
||||
tokenSwapInfo = transferState.fromTokenInfo,
|
||||
appCurrency = transferState.appCurrency,
|
||||
isFeeSubtractedFromAmount = isFeeSubtractedFromAmount(transferState, fee),
|
||||
isFeeExceedingBalance = isFeeExceedingBalance(transferState, fee),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getTransferButtonEnabled(notifications: ImmutableList<NotificationUM>, fee: Fee?): Boolean {
|
||||
return fee != null && notifications.none { notification ->
|
||||
private fun getTransferButtonEnabled(
|
||||
notifications: ImmutableList<NotificationUM>,
|
||||
fee: Fee?,
|
||||
isTangemPayWithdrawal: Boolean,
|
||||
): Boolean {
|
||||
return (fee != null || isTangemPayWithdrawal) && notifications.none { notification ->
|
||||
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
|
||||
notification is SwapNotificationUM.Warning.ExpressErrorWarning ||
|
||||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||
|
||||
|
|
@ -253,23 +386,50 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun isFeeSubtractedFromAmount(transferState: SwapState.Transfer, fee: Fee?): Boolean {
|
||||
if (!transferState.isAmountSubtractAvailable || fee == null) return false
|
||||
val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus
|
||||
val balance = swapCurrencyStatus.status.value.amount.orZero()
|
||||
val amountValue = transferState.fromTokenInfo.tokenAmount.value
|
||||
return amountValue + fee.amount.value.orZero() > balance
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the fee alone exceeds the balance: nothing can be sent (not even enough to cover the fee), so
|
||||
* the footer must show $0 instead of a positive total. Only meaningful when the fee is paid from the same
|
||||
* balance (subtraction available).
|
||||
*/
|
||||
private fun isFeeExceedingBalance(transferState: SwapState.Transfer, fee: Fee?): Boolean {
|
||||
if (!transferState.isAmountSubtractAvailable || fee == null) return false
|
||||
val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus
|
||||
val balance = swapCurrencyStatus.status.value.amount.orZero()
|
||||
return fee.amount.value.orZero() > balance
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun getSendingFooterText(
|
||||
dataState: SwapProcessDataState,
|
||||
fee: Fee?,
|
||||
tokenSwapInfo: TokenSwapInfo,
|
||||
appCurrency: AppCurrency,
|
||||
isFeeSubtractedFromAmount: Boolean,
|
||||
isFeeExceedingBalance: Boolean,
|
||||
): TextReference? {
|
||||
if (fee == null) return null
|
||||
|
||||
val fiatAmountValue = tokenSwapInfo.amountFiat
|
||||
val status = dataState.fromSwapCurrencyStatus?.status ?: return null
|
||||
val fiatFeeValue = fee.amount.value
|
||||
val value = dataState.feePaidCryptoCurrency?.value
|
||||
val fiatFeeValue = value?.fiatRate?.multiply(fee.amount.value)
|
||||
val isFeeConvertibleToFiat = status.currency.network.hasFiatFeeRate
|
||||
|
||||
val fiatSendingValue = if (isFeeConvertibleToFiat) {
|
||||
fiatFeeValue?.let { fiatAmountValue.plus(it) }
|
||||
} else {
|
||||
fiatAmountValue
|
||||
val fiatSendingValue = when {
|
||||
!isFeeConvertibleToFiat -> fiatAmountValue
|
||||
// Fee alone exceeds the balance → the transaction can't go through, nothing is sent.
|
||||
isFeeExceedingBalance -> BigDecimal.ZERO
|
||||
// Fee is taken out of the entered amount → it already includes the fee, don't add it again.
|
||||
isFeeSubtractedFromAmount -> fiatAmountValue
|
||||
else -> fiatFeeValue?.let { fiatAmountValue.plus(it) }
|
||||
}
|
||||
|
||||
val fiatSending = fiatSendingValue.format {
|
||||
|
|
@ -280,8 +440,11 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
}
|
||||
|
||||
val networkId = status.currency.network.id
|
||||
// When the fee is convertible to fiat, show the fiat-converted value; otherwise keep the raw
|
||||
// crypto fee amount — formatFooterFiatFee renders amount.value as crypto in the non-fiat case.
|
||||
val feeAmount = if (isFeeConvertibleToFiat) fee.amount.copy(value = fiatFeeValue) else fee.amount
|
||||
val fiatFee = formatFooterFiatFee(
|
||||
amount = fee.amount.copy(value = fiatFeeValue),
|
||||
amount = feeAmount,
|
||||
isFeeConvertibleToFiat = isFeeConvertibleToFiat,
|
||||
isFeeApproximate = isFeeApproximateUseCase(networkId = networkId, amountType = fee.amount.type),
|
||||
appCurrency = appCurrency,
|
||||
|
|
@ -296,9 +459,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
} else {
|
||||
resourceReference(
|
||||
id = if (isFeeConvertibleToFiat) {
|
||||
com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description
|
||||
com.tangem.features.send.impl.R.string.send_summary_transaction_description
|
||||
} else {
|
||||
com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description_no_fiat_fee
|
||||
com.tangem.features.send.impl.R.string.send_summary_transaction_description_no_fiat_fee
|
||||
},
|
||||
formatArgs = wrappedList(fiatSending, fiatFee),
|
||||
)
|
||||
|
|
@ -318,13 +481,13 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
fun createSuccessState(
|
||||
uiState: SwapStateHolder,
|
||||
dataState: SwapProcessDataState,
|
||||
appCurrency: AppCurrency,
|
||||
isAccountsMode: Boolean,
|
||||
fee: Fee?,
|
||||
txUrl: String,
|
||||
timestamp: Long,
|
||||
fee: TextReference?,
|
||||
onExplorerClick: () -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
): SwapStateHolder {
|
||||
val transferState = requireNotNull(dataState.currentTransferState)
|
||||
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
|
||||
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
|
||||
val amount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
|
|
@ -332,14 +495,14 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
val fromCurrency = fromSwapCurrencyStatus.currency
|
||||
val toCurrency = toSwapCurrencyStatus.currency
|
||||
val fromAmountText = amount.format { crypto(fromCurrency.symbol, fromCurrency.decimals) }
|
||||
val toAmountText = amount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
|
||||
val toAmountText = transferState.sendingAmount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
|
||||
val fromFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = appCurrency,
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
|
||||
)
|
||||
val toFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = appCurrency,
|
||||
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(transferState.sendingAmount),
|
||||
)
|
||||
|
||||
return uiState.copy(
|
||||
|
|
@ -352,15 +515,15 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
isTransferMode = true,
|
||||
providerIcon = "",
|
||||
rate = TextReference.EMPTY,
|
||||
fee = fee,
|
||||
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
|
||||
fromTitle = getCardAccountTitle(
|
||||
account = fromSwapCurrencyStatus.account,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = true,
|
||||
),
|
||||
toTitle = getCardAccountTitle(
|
||||
account = toSwapCurrencyStatus.account,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
),
|
||||
fromTokenAmount = stringReference(fromAmountText),
|
||||
|
|
@ -369,9 +532,90 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
toTokenFiatAmount = toFiatAmount,
|
||||
fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status),
|
||||
toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status),
|
||||
onExploreButtonClick = onExplorerClick,
|
||||
navigationUM = swapSuccessNavigation(
|
||||
txUrl = txUrl,
|
||||
exploreClick = onExplorerClick,
|
||||
shareClick = onShareClick,
|
||||
),
|
||||
onStatusButtonClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createTangemPayWithdrawalSuccessState(
|
||||
uiState: SwapStateHolder,
|
||||
dataState: SwapProcessDataState,
|
||||
fee: Fee?,
|
||||
onExploreClick: () -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
): SwapStateHolder {
|
||||
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
|
||||
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
|
||||
val transferState = requireNotNull(dataState.currentTransferState)
|
||||
val fromAmount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
val toAmount = transferState.sendingAmount
|
||||
val fromFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(fromAmount),
|
||||
)
|
||||
val toFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount),
|
||||
)
|
||||
|
||||
return uiState.copy(
|
||||
successState = SwapSuccessStateHolder(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
txUrl = "",
|
||||
providerName = stringReference(""),
|
||||
providerType = stringReference(""),
|
||||
shouldShowStatusButton = false,
|
||||
isTransferMode = true,
|
||||
providerIcon = "",
|
||||
rate = TextReference.EMPTY,
|
||||
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
|
||||
fromTitle = getCardAccountTitle(
|
||||
account = fromSwapCurrencyStatus.account,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = true,
|
||||
),
|
||||
toTitle = getCardAccountTitle(
|
||||
account = toSwapCurrencyStatus.account,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
),
|
||||
fromTokenAmount = stringReference(fromAmount.toString()),
|
||||
toTokenAmount = stringReference(toAmount.toString()),
|
||||
fromTokenFiatAmount = fromFiatAmount,
|
||||
toTokenFiatAmount = toFiatAmount,
|
||||
fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status),
|
||||
toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status),
|
||||
navigationUM = swapSuccessNavigation(
|
||||
txUrl = "",
|
||||
exploreClick = onExploreClick,
|
||||
shareClick = onShareClick,
|
||||
),
|
||||
onStatusButtonClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatFeeForSuccess(transferState: SwapState.Transfer, fee: Fee): TextReference {
|
||||
val feeAmount = fee.amount
|
||||
val totalFeeValue = feeAmount.value ?: BigDecimal.ZERO
|
||||
val cryptoFormatted = totalFeeValue.format {
|
||||
crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals)
|
||||
}
|
||||
val appCurrency = transferState.appCurrency
|
||||
val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus
|
||||
val fiatRate = swapCurrencyStatus.status.value.fiatRate
|
||||
val fiatFormatted = fiatRate?.multiply(totalFeeValue).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
return stringReference("$cryptoFormatted ($fiatFormatted)")
|
||||
}
|
||||
|
||||
fun updateTransferTitle(uiState: SwapStateHolder): SwapStateHolder {
|
||||
return uiState.copy(titleId = R.string.common_transfer)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue