Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-18 18:16:27 +05:00
parent c1ad1e43ce
commit 68fbe17731
18 changed files with 968 additions and 29 deletions

View file

@ -0,0 +1,58 @@
package com.tangem.common.ui.footers
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.res.TangemTheme
/**
* Sending info text with display animation.
* Must show text only when keyboard is closed
*
* @param footerText text to display
* @param modifier composable modifier
*/
@Composable
fun SendingText(footerText: TextReference, modifier: Modifier = Modifier) {
var isVisibleProxy by remember { mutableStateOf(footerText != TextReference.EMPTY) }
val keyboard by keyboardAsState()
// the text should appear when the keyboard is closed
LaunchedEffect(footerText != TextReference.EMPTY, keyboard) {
if (footerText != TextReference.EMPTY && keyboard is Keyboard.Opened) {
return@LaunchedEffect
}
isVisibleProxy = footerText != TextReference.EMPTY
}
AnimatedVisibility(
visible = isVisibleProxy,
modifier = modifier,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = fadeOut(tween(durationMillis = 300)),
label = "Animate show sending state text",
) {
Text(
text = footerText.resolveAnnotatedReference(),
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
)
}
}

View file

@ -9,4 +9,13 @@ fun interface ComposableContentComponent {
@Composable
fun Content(modifier: Modifier)
}
fun getEmptyComposableContentComponent() = EmptyComposableContentComponent
object EmptyComposableContentComponent : ComposableContentComponent {
@Composable
override fun Content(modifier: Modifier) {
/* no-op */
}
}

View file

@ -203,7 +203,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
fromDecimals = fromCryptoCurrency.decimals,
toDecimals = toCryptoCurrency.decimals,
fromAmount = fromAmount,
providerId = expressProvider.name,
providerId = expressProvider.providerId,
rateType = rateType.name.lowercase(),
requestId = requestId,
refundAddress = refundData?.refundAddress,

View file

@ -27,6 +27,7 @@ class SwapTransactionSentUseCase(
swapDataTransactionModel: SwapDataTransactionModel,
provider: ExpressProvider,
txHash: String,
timestamp: Long,
) = Either.catch {
swapRepositoryV2.swapTransactionSent(
userWallet = userWallet,
@ -37,7 +38,6 @@ class SwapTransactionSentUseCase(
txExtraId = swapDataTransactionModel.txExtraId,
)
if (provider.type.shouldStoreSwapTransaction()) {
val timestamp = System.currentTimeMillis()
swapTransactionRepository.storeTransaction(
userWalletId = userWallet.walletId,
fromCryptoCurrency = fromCryptoCurrencyStatus.currency,

View file

@ -24,7 +24,7 @@ internal fun NFTContent(stackState: ChildStack<NFTRoute, ComposableContentCompon
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.Companion.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Children(
stack = stackState,

View file

@ -23,12 +23,12 @@ internal fun SendContent(
stackState: ChildStack<CommonSendRoute, ComposableContentComponent>,
) {
Column(
modifier = Modifier.Companion
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.Companion.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally,
) {
SendAppBar(navigationUM = navigationUM)
Children(

View file

@ -13,13 +13,21 @@ dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
/** Common */
implementation(projects.common.ui)
/** Domain */
implementation(projects.domain.wallets.models)
implementation(projects.domain.express.models)
implementation(projects.domain.swap.models)
implementation(projects.domain.manageTokens.models)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.appCurrency.models)
/** Compose */
implementation(deps.compose.runtime)
/** Other */
implementation(deps.kotlin.immutable.collections)
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.swap.v2.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
interface SendWithSwapComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val callback: ModelCallback? = null,
)
interface Factory : ComponentFactory<Params, SendWithSwapComponent>
interface ModelCallback {
fun onCloseSwap(lastAmount: String)
}
}

View file

@ -60,6 +60,8 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.settings)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.txhistory)
/** Compose */
implementation(deps.compose.foundation)

View file

@ -0,0 +1,202 @@
package com.tangem.features.swap.v2.impl.sendviaswap
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.value.ObserveLifecycleMode
import com.arkivanov.decompose.value.subscribe
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.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.getEmptyComposableContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.swap.models.R
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.features.send.v2.api.subcomponents.destination.DestinationRoute
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.SendWithSwapConfirmComponent
import com.tangem.features.swap.v2.impl.sendviaswap.model.SendWithSwapModel
import com.tangem.features.swap.v2.impl.sendviaswap.success.SendWithSwapSuccessComponent
import com.tangem.features.swap.v2.impl.sendviaswap.ui.SendWithSwapContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
internal class DefaultSendWithSwapComponent @AssistedInject constructor(
@Assisted private val appComponentContext: AppComponentContext,
@Assisted private val params: SendWithSwapComponent.Params,
private val sendDestinationComponentFactory: SendDestinationComponent.Factory,
private val confirmComponentFactory: SendWithSwapConfirmComponent.Factory,
) : SendWithSwapComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<SendWithSwapRoute>()
private val innerRouter = InnerRouter<SendWithSwapRoute>(
stackNavigation = stackNavigation,
popCallback = { onChildBack() },
)
private val model: SendWithSwapModel = getOrCreateModel(params = params, router = innerRouter)
private val childStack = childStack(
key = "sendWithSwapInnerStack",
source = stackNavigation,
serializer = null,
initialConfiguration = model.initialRoute,
handleBackButton = true,
childFactory = { configuration, componentContext ->
createChild(
route = configuration,
childContext = childByContext(
componentContext = componentContext,
router = innerRouter,
),
)
},
)
init {
childStack.subscribe(
lifecycle = lifecycle,
mode = ObserveLifecycleMode.CREATE_DESTROY,
) { stack ->
componentScope.launch {
when (val activeComponent = stack.active.instance) {
is SwapAmountComponent -> {
// todo send with swap analytics
activeComponent.updateState(model.uiState.value.amountUM)
}
is SendDestinationComponent -> {
// todo send with swap analytics
activeComponent.updateState(model.uiState.value.destinationUM)
}
is SendWithSwapConfirmComponent -> if (model.currentRoute.value.isEditMode) {
// todo send with swap analytics
activeComponent.updateState(model.uiState.value)
}
}
model.currentRoute.emit(stack.active.configuration)
}
}
}
@Composable
override fun Content(modifier: Modifier) {
val stackState by childStack.subscribeAsState()
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler(onBack = ::onChildBack)
SendWithSwapContent(navigationUM = state.navigationUM, stackState = stackState)
}
private fun createChild(route: SendWithSwapRoute, childContext: AppComponentContext) = when (route) {
is SendWithSwapRoute.Amount -> getAmountComponent(factoryContext = childContext)
is SendWithSwapRoute.Destination -> getDestinationComponent(factoryContext = childContext)
is SendWithSwapRoute.Confirm -> getConfirmComponent(factoryContext = childContext)
is SendWithSwapRoute.Success -> getSuccessComponent(factoryContext = childContext)
}
private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent {
return SwapAmountComponent(
appComponentContext = factoryContext,
params = SwapAmountComponentParams.AmountParams(
amountUM = model.uiState.value.amountUM,
title = resourceReference(R.string.common_send),
currentRoute = model.currentRoute.filterIsInstance<SendWithSwapRoute.Amount>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = "",
primaryCryptoCurrencyStatusFlow = model.primaryCryptoCurrencyStatusFlow,
secondaryCryptoCurrency = null,
swapDirection = SwapDirection.Direct,
callback = model,
userWallet = model.userWallet,
),
)
}
private fun getDestinationComponent(factoryContext: AppComponentContext): ComposableContentComponent {
val amountContentUM = model.uiState.value.amountUM as? SwapAmountUM.Content
?: return getEmptyComposableContentComponent()
val secondaryCryptoCurrency = amountContentUM.secondaryCryptoCurrencyStatus?.currency
?: return getEmptyComposableContentComponent()
return sendDestinationComponentFactory.create(
context = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = model.currentRoute.filterIsInstance<DestinationRoute>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = "",
title = resourceReference(R.string.send_recipient_label),
userWalletId = params.userWalletId,
cryptoCurrency = secondaryCryptoCurrency,
callback = model,
onBackClick = ::onChildBack,
onNextClick = model::onNextClick,
),
)
}
private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableContentComponent {
return confirmComponentFactory.create(
appComponentContext = factoryContext,
params = SendWithSwapConfirmComponent.Params(
sendWithSwapUM = model.uiState.value,
currentRoute = model.currentRoute.filterIsInstance<SendWithSwapRoute.Confirm>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
appCurrency = model.appCurrency,
userWallet = model.userWallet,
callback = model,
analyticsCategoryName = "",
primaryCryptoCurrencyStatusFlow = model.primaryCryptoCurrencyStatusFlow,
primaryFeePaidCurrencyStatusFlow = model.primaryFeePaidCurrencyStatusFlow,
swapDirection = SwapDirection.Direct,
),
)
}
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent {
return SendWithSwapSuccessComponent(
appComponentContext = factoryContext,
params = SendWithSwapSuccessComponent.Params(
sendWithSwapUMFlow = model.uiState,
currentRoute = model.currentRoute.filterIsInstance<SendWithSwapRoute.Success>(),
callback = model,
analyticsCategoryName = "",
),
)
}
private fun onChildBack() {
val isEmptyStack = childStack.value.backStack.isEmpty()
if (isEmptyStack) {
router.pop()
} else {
stackNavigation.pop()
}
}
@AssistedFactory
interface Factory : SendWithSwapComponent.Factory {
override fun create(
context: AppComponentContext,
params: SendWithSwapComponent.Params,
): DefaultSendWithSwapComponent
}
}

View file

@ -22,15 +22,17 @@ import com.tangem.features.send.v2.api.subcomponents.destination.SendDestination
import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.SendWithSwapConfirmModel
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.ui.SendWithSwapConfirmContent
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.utils.extensions.orZero
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.*
import java.math.BigDecimal
internal class SendWithSwapConfirmComponent @AssistedInject constructor(
@Assisted private val appComponentContext: AppComponentContext,
@ -88,7 +90,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
)
private val sendNotificationsComponent = sendNotificationsComponentFactory.create(
context = appComponentContext.childByContext(child("sendWithSwapConfirmNotifications")),
context = appComponentContext.childByContext(child("sendWithSwapConfirmSendNotifications")),
params = SendNotificationsComponent.Params(
analyticsCategoryName = params.analyticsCategoryName,
userWalletId = params.userWallet.walletId,
@ -96,14 +98,23 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
feeCryptoCurrencyStatus = model.primaryFeePaidCurrencyStatus,
appCurrency = params.appCurrency,
notificationData = SendNotificationsComponent.Params.NotificationData(
// todo fill with data
destinationAddress = "",
memo = "",
amountValue = BigDecimal.ZERO,
reduceAmountBy = BigDecimal.ZERO,
isIgnoreReduce = false,
fee = null,
feeError = null,
destinationAddress = model.confirmData.enteredDestination.orEmpty(),
memo = null,
amountValue = model.confirmData.enteredAmount.orZero(),
reduceAmountBy = model.confirmData.reduceAmountBy.orZero(),
isIgnoreReduce = model.confirmData.isIgnoreReduce,
fee = model.confirmData.fee,
feeError = model.confirmData.feeError,
),
),
)
private val swapNotificationsComponent = SwapNotificationsComponent(
appComponentContext = appComponentContext.childByContext(child("sendWithSwapConfirmSwapNotifications")),
params = SwapNotificationsComponent.Params(
swapNotificationData = SwapNotificationsComponent.Params.SwapNotificationData(
expressError = (model.confirmData.quote as? SwapQuoteUM.Error)?.expressError,
fromCryptoCurrency = model.confirmData.fromCryptoCurrencyStatus?.currency,
),
),
)
@ -126,6 +137,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
override fun Content(modifier: Modifier) {
val sendWithSwapUM by model.uiState.collectAsStateWithLifecycle()
val sendNotificationsUM by sendNotificationsComponent.state.collectAsStateWithLifecycle()
val swapNotificationsUM by swapNotificationsComponent.state.collectAsStateWithLifecycle()
SendWithSwapConfirmContent(
sendWithSwapUM = sendWithSwapUM,
@ -134,6 +146,8 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
feeSelectorBlockComponent = feeSelectorBlockComponent,
sendNotificationsComponent = sendNotificationsComponent,
sendNotificationsUM = sendNotificationsUM,
swapNotificationsComponent = swapNotificationsComponent,
swapNotificationsUM = swapNotificationsUM,
modifier = modifier,
)
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.swap.v2.impl.sendviaswap.confirm.model
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import java.math.BigDecimal
internal data class ConfirmData(
val enteredAmount: BigDecimal?,
val reduceAmountBy: BigDecimal,
val isIgnoreReduce: Boolean,
val enteredDestination: String?,
val fee: Fee?,
val feeError: GetFeeError?,
val fromCryptoCurrencyStatus: CryptoCurrencyStatus?,
val toCryptoCurrencyStatus: CryptoCurrencyStatus?,
val quote: SwapQuoteUM?,
val rateType: ExpressRateType?,
)

View file

@ -20,16 +20,21 @@ import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.common.SwapUtils.INCREASE_GAS_LIMIT_FOR_CEX
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent.Params.SwapNotificationData
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsUpdateListener
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsUpdateTrigger
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.SendWithSwapConfirmComponent
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.transformers.SendWithSwapConfirmInitialStateTransformer
@ -51,7 +56,12 @@ internal class SendWithSwapConfirmModel @Inject constructor(
private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase,
private val estimateFeeUseCase: EstimateFeeUseCase,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val sendNotificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val swapNotificationsUpdateTrigger: SwapNotificationsUpdateTrigger,
private val sendNotificationsUpdateListener: SendNotificationsUpdateListener,
private val swapNotificationsUpdateListener: SwapNotificationsUpdateListener,
swapTransactionSenderFactory: SwapTransactionSender.Factory,
paramsContainer: ParamsContainer,
) : Model(), FeeSelectorModelCallback {
@ -61,20 +71,57 @@ internal class SendWithSwapConfirmModel @Inject constructor(
field = MutableStateFlow(params.sendWithSwapUM)
val primaryCurrencyStatus: CryptoCurrencyStatus = params.primaryCryptoCurrencyStatusFlow.value
val secondaryCurrencyStatus: CryptoCurrencyStatus? = amountUM?.secondaryCryptoCurrencyStatus
val primaryFeePaidCurrencyStatus: CryptoCurrencyStatus = params.primaryFeePaidCurrencyStatusFlow.value
private val amountUM = uiState.value.amountUM as? SwapAmountUM.Content
val primaryCurrency: CryptoCurrency = primaryCurrencyStatus.currency
val secondaryCurrency: CryptoCurrency = requireNotNull(amountUM?.secondaryCryptoCurrencyStatus?.currency) {
"Crypto currency must not be null"
}
private val swapTransactionSender = swapTransactionSenderFactory.create(params.userWallet)
private var isAmountSubtractAvailable = false
private val amountUM
get() = uiState.value.amountUM as? SwapAmountUM.Content
private val destinationUM
get() = uiState.value.destinationUM as? DestinationUM.Content
private val feeSelectorUM
get() = uiState.value.feeSelectorUM as? FeeSelectorUM.Content
val confirmData: ConfirmData
get() {
val amountUM = amountUM
val fromAmount = amountUM?.swapDirection?.withSwapDirection(
onDirect = { amountUM.primaryAmount },
onReverse = { amountUM.secondaryAmount },
)
val amountState = fromAmount?.amountField as? AmountState.Data
return ConfirmData(
enteredAmount = amountState?.amountTextField?.cryptoAmount?.value,
reduceAmountBy = amountState?.reduceAmountBy.orZero(),
isIgnoreReduce = amountState?.isIgnoreReduce == true,
enteredDestination = destinationUM?.addressTextField?.actualAddress,
fee = feeSelectorUM?.selectedFeeItem?.fee,
feeError = (uiState.value.feeSelectorUM as? FeeSelectorUM.Error)?.error,
fromCryptoCurrencyStatus = amountUM?.swapDirection?.withSwapDirection(
onDirect = { primaryCurrencyStatus },
onReverse = { secondaryCurrencyStatus },
),
toCryptoCurrencyStatus = amountUM?.swapDirection?.withSwapDirection(
onDirect = { secondaryCurrencyStatus },
onReverse = { primaryCurrencyStatus },
),
quote = amountUM?.selectedQuote,
rateType = amountUM?.swapRateType,
)
}
init {
initAmountSubtractAvailability()
configConfirmNavigation()
initialState()
subscribeOnNotificationUpdates()
}
override fun onFeeResult(feeSelectorUM: FeeSelectorUM) {
@ -127,10 +174,6 @@ internal class SendWithSwapConfirmModel @Inject constructor(
}
ExpressProviderType.DEX,
ExpressProviderType.DEX_BRIDGE,
-> {
// todo send with swap
GetFeeError.UnknownError.left()
}
ExpressProviderType.ONRAMP,
-> GetFeeError.DataError(
cause = IllegalStateException("Provider $providerType is not supported in Send With Swap"),
@ -139,7 +182,38 @@ internal class SendWithSwapConfirmModel @Inject constructor(
}
private fun onSendClick() {
// todo swap send tx
val provider = confirmData.quote?.provider ?: return
modelScope.launch {
swapTransactionSender.sendTransaction(
confirmData = confirmData,
isAmountSubtractAvailable = isAmountSubtractAvailable,
onExpressError = {
// todo error
},
onSendError = {
// todo error
},
onSendSuccess = { txHash, timestamp, data ->
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txHash,
networkId = primaryCurrencyStatus.currency.network.id,
).getOrNull().orEmpty()
uiState.update {
it.copy(
confirmUM = ConfirmUM.Success(
isPrimaryButtonEnabled = true,
transactionDate = timestamp,
txUrl = txUrl,
provider = provider,
swapDataModel = data,
),
)
}
router.replaceAll(SendWithSwapRoute.Success)
},
)
}
}
private fun initAmountSubtractAvailability() {
@ -194,12 +268,38 @@ internal class SendWithSwapConfirmModel @Inject constructor(
feeError = feeSelectorUMError?.error,
),
)
swapNotificationsUpdateTrigger.triggerUpdate(
data = SwapNotificationData(
expressError = (amountUM.selectedQuote as? SwapQuoteUM.Error)?.expressError,
fromCryptoCurrency = amountUM.swapDirection.withSwapDirection(
onDirect = { primaryCurrency },
onReverse = { secondaryCurrency },
),
),
)
uiState.transformerUpdate(
SendWithSwapConfirmationNotificationsTransformer(),
)
}
}
private fun subscribeOnNotificationUpdates() {
combine(
flow = sendNotificationsUpdateListener.hasErrorFlow,
flow2 = swapNotificationsUpdateListener.hasErrorFlow,
) { hasSendError, hasSwapError ->
val hasError = hasSendError || hasSwapError
uiState.update {
val feeUM = it.feeSelectorUM as? FeeSelectorUM.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeUM != null,
) ?: it.confirmUM,
)
}
}.launchIn(modelScope)
}
private fun configConfirmNavigation() {
combine(
flow = uiState,

View file

@ -0,0 +1,172 @@
package com.tangem.features.swap.v2.impl.sendviaswap.confirm.model
import arrow.core.getOrElse
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.swap.models.SwapDataModel
import com.tangem.domain.swap.models.SwapDataTransactionModel
import com.tangem.domain.swap.usecase.GetSwapDataUseCase
import com.tangem.domain.swap.usecase.SwapTransactionSentUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import timber.log.Timber
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class SwapTransactionSender @AssistedInject constructor(
private val getSwapDataUseCase: GetSwapDataUseCase,
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
private val swapTransactionSentUseCase: SwapTransactionSentUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
@Assisted private val userWallet: UserWallet,
) {
suspend fun sendTransaction(
confirmData: ConfirmData,
isAmountSubtractAvailable: Boolean,
onSendSuccess: (String, Long, SwapDataModel) -> Unit,
onExpressError: (ExpressError) -> Unit,
onSendError: (SendTransactionError?) -> Unit,
) {
val provider = confirmData.quote?.provider ?: return
when (val providerType = provider.type) {
ExpressProviderType.CEX -> onCexTransaction(
confirmData = confirmData,
isAmountSubtractAvailable = isAmountSubtractAvailable,
onExpressError = onExpressError,
onSendSuccess = onSendSuccess,
onSendError = onSendError,
)
ExpressProviderType.DEX,
ExpressProviderType.DEX_BRIDGE,
ExpressProviderType.ONRAMP,
-> {
Timber.w("Provider $providerType is not supported in Send With Swap")
onExpressError(ExpressError.UnknownError)
}
}
}
suspend fun onCexTransaction(
confirmData: ConfirmData,
isAmountSubtractAvailable: Boolean,
onSendSuccess: (String, Long, SwapDataModel) -> Unit,
onExpressError: (ExpressError) -> Unit,
onSendError: (SendTransactionError?) -> Unit,
) {
val fromStatus = confirmData.fromCryptoCurrencyStatus ?: return
val toStatus = confirmData.toCryptoCurrencyStatus ?: return
val provider = confirmData.quote?.provider ?: return
val rateType = confirmData.rateType ?: return
val amountValue = confirmData.enteredAmount ?: return
val feeValue = confirmData.fee?.amount?.value ?: return
val fromAmount = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = fromStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = confirmData.reduceAmountBy,
)
val swapData = getSwapDataUseCase(
userWallet = userWallet,
fromCryptoCurrencyStatus = fromStatus,
fromAmount = fromAmount.toStringWithRightOffset(fromStatus.currency.decimals),
toCryptoCurrencyStatus = toStatus,
toAddress = confirmData.enteredDestination,
expressProvider = provider,
rateType = rateType,
).getOrElse { onExpressError(it); return }
createAndSendCexTransaction(
fromAmount = fromAmount,
fromStatus = fromStatus,
toStatus = toStatus,
fee = confirmData.fee,
provider = provider,
swapData = swapData,
onSendSuccess = onSendSuccess,
onExpressError = onExpressError,
onSendError = onSendError,
)
}
private suspend fun createAndSendCexTransaction(
fromAmount: BigDecimal,
fromStatus: CryptoCurrencyStatus,
toStatus: CryptoCurrencyStatus,
fee: Fee,
provider: ExpressProvider,
swapData: SwapDataModel,
onSendSuccess: (String, Long, SwapDataModel) -> Unit,
onExpressError: (ExpressError) -> Unit,
onSendError: (SendTransactionError?) -> Unit,
) {
val swapTransaction = if (swapData.transaction is SwapDataTransactionModel.CEX) {
swapData.transaction
} else {
onExpressError(ExpressError.UnknownError)
return
}
val txData = createTransferTransactionUseCase(
amount = fromAmount.convertToSdkAmount(fromStatus.currency),
fee = fee,
memo = swapTransaction.txExtraId,
destination = swapTransaction.txTo,
userWalletId = userWallet.walletId,
network = fromStatus.currency.network,
).getOrElse {
Timber.e(it, "Failed to create swap CEX tx data")
onSendError(SendTransactionError.UnknownError(Exception(it)))
return
}
if (txData.extras == null && swapTransaction.txExtraId != null) {
onExpressError(ExpressError.UnknownError)
return
}
sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = fromStatus.currency.network,
).fold(
ifLeft = { onSendError(it) },
ifRight = { txHash ->
val timestamp = System.currentTimeMillis()
swapTransactionSentUseCase.invoke(
userWallet = userWallet,
fromCryptoCurrencyStatus = fromStatus,
toCryptoCurrencyStatus = toStatus,
swapDataTransactionModel = swapTransaction,
provider = provider,
txHash = txHash,
timestamp = timestamp,
)
onSendSuccess(txHash, timestamp, swapData)
},
)
}
private fun BigDecimal.toStringWithRightOffset(decimals: Int): String {
return movePointRight(decimals).toPlainString()
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): SwapTransactionSender
}
}

View file

@ -10,13 +10,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.footers.SendingText
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.notifications
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import kotlinx.collections.immutable.ImmutableList
@ -29,6 +33,8 @@ internal fun SendWithSwapConfirmContent(
feeSelectorBlockComponent: FeeSelectorBlockComponent,
sendNotificationsComponent: SendNotificationsComponent,
sendNotificationsUM: ImmutableList<NotificationUM>,
swapNotificationsComponent: SwapNotificationsComponent,
swapNotificationsUM: ImmutableList<NotificationUM>,
modifier: Modifier = Modifier,
) {
val confirmUM = sendWithSwapUM.confirmUM as? ConfirmUM.Content
@ -53,20 +59,25 @@ internal fun SendWithSwapConfirmContent(
}
if (confirmUM != null) {
// tapHelp(isDisplay = confirmUM.showTapHelp) // todo
with(swapNotificationsComponent) {
content(
state = swapNotificationsUM,
isClickDisabled = confirmUM.isTransactionInProcess,
)
}
with(sendNotificationsComponent) {
content(
state = sendNotificationsUM,
isClickDisabled = confirmUM.isTransactionInProcess,
)
}
// notifications(
// notifications = confirmUM.notifications,
// isClickDisabled = confirmUM.isTransactionInProcess,
// )
notifications(
notifications = confirmUM.notifications,
isClickDisabled = confirmUM.isTransactionInProcess,
)
}
}
SpacerHMax()
// todo
// SendingText(footerText = confirmUM?.sendingFooter ?: TextReference.EMPTY)
SendingText(footerText = confirmUM?.sendingFooter ?: TextReference.EMPTY)
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.swap.v2.impl.sendviaswap.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.impl.sendviaswap.DefaultSendWithSwapComponent
import com.tangem.features.swap.v2.impl.sendviaswap.model.SendWithSwapModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(ModelComponent::class)
internal interface SendWithSwapModule {
@Binds
@IntoMap
@ClassKey(SendWithSwapModel::class)
fun provideSendWithSwapModel(impl: SendWithSwapModel): Model
}
@Module
@InstallIn(SingletonComponent::class)
internal interface SendWithSwapModuleBinds {
@Binds
@Singleton
fun provideSendWithSwapComponentFactory(impl: DefaultSendWithSwapComponent.Factory): SendWithSwapComponent.Factory
}

View file

@ -0,0 +1,212 @@
package com.tangem.features.swap.v2.impl.sendviaswap.model
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponent
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.SendWithSwapConfirmComponent
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.properties.Delegates
@Suppress("LongParameterList")
@ModelScoped
internal class SendWithSwapModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
paramsContainer: ParamsContainer,
) : Model(),
SwapAmountComponent.ModelCallback,
SendDestinationComponent.ModelCallback,
SendWithSwapConfirmComponent.ModelCallback {
private val params: SendWithSwapComponent.Params = paramsContainer.require()
val initialRoute = SendWithSwapRoute.Amount(false)
val currentRoute = MutableStateFlow<SendWithSwapRoute>(initialRoute)
var userWallet: UserWallet by Delegates.notNull()
var appCurrency: AppCurrency = AppCurrency.Default
val uiState: StateFlow<SendWithSwapUM>
field = MutableStateFlow(initialState())
val isBalanceHiddenFlow: StateFlow<Boolean>
field = MutableStateFlow(false)
val primaryCryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>
field = MutableStateFlow(
CryptoCurrencyStatus(
currency = params.currency,
value = CryptoCurrencyStatus.Loading,
),
)
val primaryFeePaidCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>
field = MutableStateFlow(
CryptoCurrencyStatus(
currency = params.currency,
value = CryptoCurrencyStatus.Loading,
),
)
init {
initUserWallet()
initAppCurrency()
subscribeOnBalanceHidden()
}
override fun onAmountResult(amountUM: SwapAmountUM) {
uiState.update { it.copy(amountUM = amountUM) }
}
override fun onDestinationResult(destinationUM: DestinationUM) {
uiState.update { it.copy(destinationUM = destinationUM) }
}
override fun onResult(sendWithSwapUM: SendWithSwapUM) {
uiState.value = sendWithSwapUM
}
override fun onNavigationResult(navigationUM: NavigationUM) {
uiState.update { it.copy(navigationUM = navigationUM) }
}
override fun onSeparatorClick(lastAmount: String) {
params.callback?.onCloseSwap(lastAmount)
router.popTo(initialRoute)
}
override fun onBackClick() = router.pop()
override fun onNextClick() {
if (currentRoute.value.isEditMode) {
onBackClick()
} else {
when (currentRoute.value) {
is SendWithSwapRoute.Amount -> router.push(SendWithSwapRoute.Destination(isEditMode = false))
is SendWithSwapRoute.Destination -> router.push(SendWithSwapRoute.Confirm)
SendWithSwapRoute.Confirm -> router.push(SendWithSwapRoute.Success)
SendWithSwapRoute.Success -> onBackClick()
}
}
}
private fun initUserWallet() {
getUserWalletUseCase(params.userWalletId).fold(
ifRight = { wallet ->
userWallet = wallet
getPrimaryCurrencyStatusUpdates(params.currency)
},
ifLeft = {
Timber.w(it.toString())
// todo send with swap error
},
)
}
private fun initAppCurrency() {
modelScope.launch {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
}
}
private fun initialState(): SendWithSwapUM {
return SendWithSwapUM(
amountUM = SwapAmountUM.Empty(swapDirection = SwapDirection.Direct),
destinationUM = DestinationUM.Empty(),
feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
)
}
private fun getPrimaryCurrencyStatusUpdates(cryptoCurrency: CryptoCurrency) {
val wallet = userWallet
val isMultiCurrency = wallet.isMultiCurrency
val isSingleWalletWithToken = wallet is UserWallet.Cold &&
wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
getCurrencyStatus(
cryptoCurrency = cryptoCurrency,
isSingleWalletWithToken = isSingleWalletWithToken,
isMultiCurrency = isMultiCurrency,
).onEach { maybeCryptoCurrency ->
maybeCryptoCurrency.fold(
ifRight = { cryptoCurrencyStatus ->
primaryCryptoCurrencyStatusFlow.value = cryptoCurrencyStatus
primaryFeePaidCurrencyStatusFlow.value = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
).getOrNull() ?: cryptoCurrencyStatus
},
ifLeft = {
// todo send with swap error
},
)
}.launchIn(modelScope)
}
private fun getCurrencyStatus(
cryptoCurrency: CryptoCurrency,
isSingleWalletWithToken: Boolean,
isMultiCurrency: Boolean,
): Flow<Either<CurrencyStatusError, CryptoCurrencyStatus>> {
return when {
isSingleWalletWithToken -> getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet(
userWalletId = params.userWalletId,
currencyId = cryptoCurrency.id,
isSingleWalletWithTokens = true,
)
isMultiCurrency -> getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet(
userWalletId = params.userWalletId,
currencyId = cryptoCurrency.id,
isSingleWalletWithTokens = false,
)
else -> getSingleCryptoCurrencyStatusUseCase.invokeSingleWallet(userWalletId = params.userWalletId)
}
}
private fun subscribeOnBalanceHidden() {
getBalanceHidingSettingsUseCase()
.conflate()
.distinctUntilChanged()
.onEach { balanceHidingSettings ->
isBalanceHiddenFlow.update { balanceHidingSettings.isBalanceHidden }
}
.launchIn(modelScope)
}
}

View file

@ -0,0 +1,77 @@
package com.tangem.features.swap.v2.impl.sendviaswap.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
import com.arkivanov.decompose.extensions.compose.stack.animation.plus
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
@Composable
internal fun SendWithSwapContent(
navigationUM: NavigationUM,
stackState: ChildStack<SendWithSwapRoute, ComposableContentComponent>,
) {
val navigationUM = navigationUM as? NavigationUM.Content ?: return
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarWithBackButton(
text = navigationUM.title.resolveReference(),
onBackClick = navigationUM.backIconClick,
iconRes = navigationUM.additionalIconRes,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
Children(
stack = stackState,
animation = stackAnimation { child ->
when (child.configuration) {
SendWithSwapRoute.Confirm -> fade()
SendWithSwapRoute.Success -> slide(orientation = Orientation.Vertical) + fade()
else -> slide()
}
},
modifier = Modifier.weight(1f),
) {
it.instance.Content(Modifier.weight(1f))
}
// TODO refactor [REDACTED_TASK_KEY]
val primaryButton = navigationUM.primaryButton
Row(modifier = Modifier.padding(16.dp)) {
TangemButton(
modifier = Modifier.fillMaxWidth(),
text = primaryButton.textReference.resolveReference(),
icon = primaryButton.iconRes?.let {
TangemButtonIconPosition.End(it)
} ?: TangemButtonIconPosition.None,
enabled = primaryButton.isEnabled,
onClick = primaryButton.onClick,
showProgress = false,
colors = TangemButtonsDefaults.primaryButtonColors,
textStyle = TangemTheme.typography.subtitle1,
)
}
}
}