From 68fbe1773168f3b6b66fd55cd4665cf4a1203fb8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 18 Jul 2025 18:16:27 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/common/ui/footers/SendingText.kt | 58 +++++ .../decompose/ComposableContentComponent.kt | 9 + .../data/swap/DefaultSwapRepositoryV2.kt | 2 +- .../usecase/SwapTransactionSentUseCase.kt | 2 +- .../features/nft/common/ui/NFTContent.kt | 2 +- .../features/send/v2/common/ui/SendContent.kt | 4 +- features/swap-v2/api/build.gradle.kts | 8 + .../swap/v2/api/SendWithSwapComponent.kt | 21 ++ features/swap-v2/impl/build.gradle.kts | 2 + .../DefaultSendWithSwapComponent.kt | 202 +++++++++++++++++ .../confirm/SendWithSwapConfirmComponent.kt | 34 ++- .../sendviaswap/confirm/model/ConfirmData.kt | 21 ++ .../confirm/model/SendWithSwapConfirmModel.kt | 116 +++++++++- .../confirm/model/SwapTransactionSender.kt | 172 ++++++++++++++ .../confirm/ui/SendWithSwapConfirmContent.kt | 23 +- .../impl/sendviaswap/di/SendWithSwapModule.kt | 32 +++ .../sendviaswap/model/SendWithSwapModel.kt | 212 ++++++++++++++++++ .../sendviaswap/ui/SendWithSwapContent.kt | 77 +++++++ 18 files changed, 968 insertions(+), 29 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/footers/SendingText.kt create mode 100644 features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SendWithSwapComponent.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/ConfirmData.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/di/SendWithSwapModule.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/footers/SendingText.kt b/common/ui/src/main/java/com/tangem/common/ui/footers/SendingText.kt new file mode 100644 index 0000000000..3f880fa882 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/footers/SendingText.kt @@ -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), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableContentComponent.kt b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableContentComponent.kt index c44405f037..b242250e28 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableContentComponent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/decompose/ComposableContentComponent.kt @@ -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 */ + } } \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index 7b2442b6ac..70455744de 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -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, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt index 59ca0bb5d0..9a920dfe5c 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt @@ -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, diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt index f906d793ee..7b51d3561e 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt @@ -24,7 +24,7 @@ internal fun NFTContent(stackState: ChildStack, ) { 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( diff --git a/features/swap-v2/api/build.gradle.kts b/features/swap-v2/api/build.gradle.kts index 7530654088..fab13fd59c 100644 --- a/features/swap-v2/api/build.gradle.kts +++ b/features/swap-v2/api/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SendWithSwapComponent.kt b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SendWithSwapComponent.kt new file mode 100644 index 0000000000..bd20d9d0a5 --- /dev/null +++ b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SendWithSwapComponent.kt @@ -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 + + interface ModelCallback { + fun onCloseSwap(lastAmount: String) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/build.gradle.kts b/features/swap-v2/impl/build.gradle.kts index 8c880b498a..cb6af0648e 100644 --- a/features/swap-v2/impl/build.gradle.kts +++ b/features/swap-v2/impl/build.gradle.kts @@ -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) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt new file mode 100644 index 0000000000..e6779b3ef2 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt @@ -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() + + private val innerRouter = InnerRouter( + 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(), + 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(), + 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(), + 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(), + 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 + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index c32952b36c..4a3fc47e81 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -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, ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/ConfirmData.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/ConfirmData.kt new file mode 100644 index 0000000000..96ab992e36 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/ConfirmData.kt @@ -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?, +) \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index d89edaf05f..c3f88e7635 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -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, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt new file mode 100644 index 0000000000..cde3c10cce --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/ui/SendWithSwapConfirmContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/ui/SendWithSwapConfirmContent.kt index 4386a93cb6..9907fc14e5 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/ui/SendWithSwapConfirmContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/ui/SendWithSwapConfirmContent.kt @@ -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, + swapNotificationsComponent: SwapNotificationsComponent, + swapNotificationsUM: ImmutableList, 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) } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/di/SendWithSwapModule.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/di/SendWithSwapModule.kt new file mode 100644 index 0000000000..43e3f1e3d6 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/di/SendWithSwapModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt new file mode 100644 index 0000000000..e099309867 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt @@ -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(initialRoute) + + var userWallet: UserWallet by Delegates.notNull() + var appCurrency: AppCurrency = AppCurrency.Default + + val uiState: StateFlow + field = MutableStateFlow(initialState()) + + val isBalanceHiddenFlow: StateFlow + field = MutableStateFlow(false) + + val primaryCryptoCurrencyStatusFlow: StateFlow + field = MutableStateFlow( + CryptoCurrencyStatus( + currency = params.currency, + value = CryptoCurrencyStatus.Loading, + ), + ) + + val primaryFeePaidCurrencyStatusFlow: StateFlow + 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> { + 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) + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt new file mode 100644 index 0000000000..17355d4aa4 --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt @@ -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, +) { + 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, + ) + } + } +} \ No newline at end of file