From c96e72fac40cab2e9b453c99f01460ba5809a8c7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 12:00:27 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../send/sendnft/DefaultNFTSendComponent.kt | 163 +++++++++--------- .../confirm/NFTSendConfirmComponent.kt | 62 ++++++- .../confirm/model/NFTSendConfirmModel.kt | 66 ++----- .../send/sendnft/model/NFTSendModel.kt | 25 +-- .../success/NFTSendSuccessComponent.kt | 66 ++++++- .../success/model/NFTSendSuccessModel.kt | 80 ++------- .../success/ui/NFTSendSuccessContent.kt | 41 ++--- .../send/sendnft/ui/state/NFTSendUM.kt | 4 +- .../model/NFTSendModelNavigationTest.kt | 137 +++++++++++++++ 9 files changed, 380 insertions(+), 264 deletions(-) create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt index e06f11e545..05b948fa64 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt @@ -4,7 +4,6 @@ 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 @@ -16,14 +15,15 @@ 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.ComposableModularContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.destination.DestinationRoute import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.common.ui.SendContent +import com.tangem.features.send.common.ui.SendModularContent import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.impl.R import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent @@ -32,16 +32,14 @@ import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch internal class DefaultNFTSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: NFTSendComponent.Params, + private val destinationComponentFactory: SendDestinationComponent.Factory, private val nftSendConfirmComponentFactory: NFTSendConfirmComponent.Factory, private val nftSendSuccessComponentFactory: NFTSendSuccessComponent.Factory, private val analyticsEventHandler: AnalyticsEventHandler, - private val sendDestinationComponentFactory: SendDestinationComponent.Factory, ) : NFTSendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -77,37 +75,35 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( lifecycle = lifecycle, mode = ObserveLifecycleMode.CREATE_DESTROY, ) { stack -> - componentScope.launch { - when (val activeComponent = stack.active.instance) { - is NFTSendConfirmComponent -> { - val fromCurrency = model.cryptoCurrency - val fromDerivationIndex = model.account?.derivationIndex?.value - .takeIf { model.isAccountsMode } - analyticsEventHandler.send( - CommonSendAnalyticEvents.ConfirmationScreenOpened( - categoryName = analyticsCategoryName, - source = analyticsSendSource, - sendBlockchain = fromCurrency.network.name, - sendToken = fromCurrency.symbol, - fromDerivationIndex = fromDerivationIndex, - toDerivationIndex = null, - ), - ) - if (model.currentRouteFlow.value.isEditMode) { - activeComponent.updateState(model.uiState.value) - } - } - is SendDestinationComponent -> { - analyticsEventHandler.send( - CommonSendAnalyticEvents.AddressScreenOpened( - categoryName = analyticsCategoryName, - source = analyticsSendSource, - ), - ) - activeComponent.updateState(model.uiState.value.destinationUM) + when (val activeComponent = stack.active.instance) { + is NFTSendConfirmComponent -> { + val fromCurrency = model.cryptoCurrency + val fromDerivationIndex = model.account?.derivationIndex?.value + .takeIf { model.isAccountsMode } + analyticsEventHandler.send( + CommonSendAnalyticEvents.ConfirmationScreenOpened( + categoryName = analyticsCategoryName, + source = analyticsSendSource, + sendBlockchain = fromCurrency.network.name, + sendToken = fromCurrency.symbol, + fromDerivationIndex = fromDerivationIndex, + toDerivationIndex = null, + ), + ) + // Push current state into a reused Confirm on (re)entry. Confirm.isEditMode is `true` + if (stack.active.configuration.isEditMode) { + activeComponent.updateState(model.uiState.value) } } - model.currentRouteFlow.emit(stack.active.configuration) + is SendDestinationComponent -> { + analyticsEventHandler.send( + CommonSendAnalyticEvents.AddressScreenOpened( + categoryName = analyticsCategoryName, + source = analyticsSendSource, + ), + ) + activeComponent.updateState(model.uiState.value.destinationUM) + } } } } @@ -115,67 +111,71 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val stackState by childStack.subscribeAsState() - val state by model.uiState.collectAsStateWithLifecycle() - BackHandler(onBack = model::onBackClick) - SendContent( - navigationUM = state.navigationUM, - confirmUM = state.confirmUM, - stackState = stackState, - ) + BackHandler(onBack = ::onChildBack) + SendModularContent(stackState = stackState) } - private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { - is CommonSendRoute.Destination -> getDestinationComponent(factoryContext) + private fun createChild( + route: CommonSendRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = when (route) { + is CommonSendRoute.Destination -> getDestinationComponent(route, factoryContext) CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) CommonSendRoute.ConfirmSuccess -> getSuccessComponent(factoryContext) - else -> getStubComponent() + // Empty is the bootstrap placeholder until the currency status resolves; NFT has no Amount step. + CommonSendRoute.Empty, + is CommonSendRoute.Amount, + -> ComposableModularContentComponent.EMPTY } - private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent = - sendDestinationComponentFactory.create( - context = factoryContext, - params = SendDestinationComponentParams.DestinationParams( - state = model.uiState.value.destinationUM, - currentRoute = model.currentRouteFlow.filterIsInstance(), - isBalanceHidingFlow = model.isBalanceHiddenFlow, - title = resourceReference(R.string.nft_send), - analyticsCategoryName = analyticsCategoryName, - analyticsSendSource = analyticsSendSource, - userWalletId = params.userWalletId, - cryptoCurrency = model.cryptoCurrency, - callback = model, - ), - ) - - private fun getConfirmComponent(factoryContext: AppComponentContext) = nftSendConfirmComponentFactory.create( - appComponentContext = factoryContext, - params = NFTSendConfirmComponent.Params( - state = model.uiState.value, - analyticsCategoryName = analyticsCategoryName, - userWallet = model.userWallet, - nftAsset = params.nftAsset, - nftCollectionName = params.nftCollectionName, - cryptoCurrencyStatus = model.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, - appCurrency = model.appCurrency, - callback = model, - currentRoute = model.currentRouteFlow.filterIsInstance(), + private fun getDestinationComponent( + route: DestinationRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = destinationComponentFactory.create( + context = factoryContext, + params = SendDestinationComponentParams.DestinationParams( + state = model.uiState.value.destinationUM, + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, - onLoadFee = model::loadFee, + title = resourceReference(R.string.nft_send), + analyticsCategoryName = analyticsCategoryName, analyticsSendSource = analyticsSendSource, - account = model.account, - isAccountsMode = model.isAccountsMode, - onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) }, + userWalletId = params.userWalletId, + cryptoCurrency = model.cryptoCurrency, + callback = model, ), ) - private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { + return nftSendConfirmComponentFactory.create( + appComponentContext = factoryContext, + params = NFTSendConfirmComponent.Params( + state = model.uiState.value, + analyticsCategoryName = analyticsCategoryName, + userWallet = model.userWallet, + nftAsset = params.nftAsset, + nftCollectionName = params.nftCollectionName, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, + appCurrency = model.appCurrency, + callback = model, + isBalanceHidingFlow = model.isBalanceHiddenFlow, + onLoadFee = model::loadFee, + analyticsSendSource = analyticsSendSource, + account = model.account, + isAccountsMode = model.isAccountsMode, + onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) }, + ), + ) + } + + private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { val txUrl = (model.uiState.value.confirmUM as? ConfirmUM.Success)?.txUrl if (txUrl == null) { model.showAlertError() - return getStubComponent() + return ComposableModularContentComponent.EMPTY } return nftSendSuccessComponentFactory.create( @@ -189,7 +189,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( nftAsset = params.nftAsset, nftCollectionName = params.nftCollectionName, callback = model, - currentRoute = model.currentRouteFlow.filterIsInstance(), txUrl = txUrl, account = model.account, isAccountsMode = model.isAccountsMode, @@ -197,8 +196,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( ) } - private fun getStubComponent() = ComposableContentComponent { } - private fun onChildBack() { val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyStack = childStack.value.backStack.isEmpty() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt index 1093c06d6c..297793f4e6 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt @@ -1,17 +1,28 @@ package com.tangem.features.send.sendnft.confirm +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import arrow.core.Either import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.footers.SendingText +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -19,21 +30,20 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.transaction.error.GetFeeError import com.tangem.features.nft.component.NFTDetailsBlockComponent -import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration -import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams -import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.sendnft.confirm.model.NFTSendConfirmModel import com.tangem.features.send.sendnft.confirm.ui.NFTSendConfirmContent import com.tangem.features.send.sendnft.ui.state.NFTSendUM import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent -import com.tangem.features.send.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -45,7 +55,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( @Assisted params: Params, nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: NFTSendConfirmModel = getOrCreateModel(params = params) @@ -129,6 +139,20 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( model.updateState(state) } + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + text = stringResourceSafe(R.string.nft_send), + onBackClick = { + model.onBackClick() + router.pop() + }, + backIconRes = R.drawable.ic_back_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -144,6 +168,29 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( ) } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + Column { + val sendingFooter = (state.confirmUM as? ConfirmUM.Content)?.sendingFooter + AnimatedVisibility( + visible = sendingFooter != null, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + ) { + SendingText(footerText = sendingFooter ?: TextReference.EMPTY) + } + NavigationPrimaryButton( + primaryButton = model.primaryButtonUM(), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + } + data class Params( val state: NFTSendUM, val analyticsCategoryName: String, @@ -157,7 +204,6 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( val account: Account.CryptoPortfolio?, val isAccountsMode: Boolean, val callback: ModelCallback, - val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, val onLoadFee: suspend () -> Either, val onSendTransaction: () -> Unit, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt index cec3676b65..98216a2934 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -5,7 +5,6 @@ import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData import com.tangem.common.routing.AppRouter import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -32,28 +31,28 @@ import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.features.nft.entity.NFTSendSuccessTrigger -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmInitialStateTransformer import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmSendingStateTransformer import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2 import com.tangem.features.send.sendnft.ui.state.NFTSendUM -import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.stripZeroPlainString import com.tangem.utils.logging.TangemLogger @@ -122,7 +121,6 @@ internal class NFTSendConfirmModel @Inject constructor( private var sendIdleTimer: Long = 0L init { - configConfirmNavigation() subscribeOnNotificationsUpdateTrigger() subscribeOnCheckFeeResultUpdates() subscribeOnTapHelpUpdates() @@ -371,54 +369,18 @@ internal class NFTSendConfirmModel @Inject constructor( } } - private fun configConfirmNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).onEach { (state, _) -> - val confirmUM = state.confirmUM - params.callback.onResult( - state.copy( - navigationUM = NavigationUM.Content( - source = CommonSendRoute.Confirm.javaClass.simpleName, - title = resourceReference(R.string.nft_send), - subtitle = null, - backIconRes = when (confirmUM) { - is ConfirmUM.Success -> R.drawable.ic_close_24 - else -> R.drawable.ic_back_24 - }, - backIconClick = { - analyticsEventHandler.send( - CommonSendAnalyticEvents.CloseButtonClicked( - categoryName = analyticsCategoryName, - source = SendScreenSource.Confirm, - isFromSummary = true, - isValid = confirmUM.isPrimaryButtonEnabled, - ), - ) - router.pop() - }, - primaryButton = primaryButtonUM(), - prevButton = null, - secondaryPairButtonsUM = ( - NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - onClick = ::onExploreClick, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - onClick = ::onShareClick, - ) - ).takeUnless { (confirmUM as? ConfirmUM.Success)?.txUrl.isNullOrBlank() }, - ), - ), - ) - }.launchIn(modelScope) + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = uiState.value.confirmUM.isPrimaryButtonEnabled, + ), + ) } - private fun primaryButtonUM(): NavigationButton { + fun primaryButtonUM(): NavigationButton { val confirmUM = uiState.value.confirmUM val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt index 46af42fff2..69094e6fb5 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt @@ -5,13 +5,13 @@ import arrow.core.Either import arrow.core.getOrElse import arrow.core.left import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic 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.Route import com.tangem.core.decompose.navigation.Router import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase @@ -36,9 +36,9 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.nft.entity.NFTSendSuccessTrigger import com.tangem.features.send.api.NFTSendComponent -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.ui.state.ConfirmUM @@ -82,8 +82,6 @@ internal class NFTSendModel @Inject constructor( val initialRoute = CommonSendRoute.Empty - val currentRouteFlow = MutableStateFlow(initialRoute) - private val userWalletId = params.userWalletId private val nftAsset = params.nftAsset @@ -107,10 +105,6 @@ internal class NFTSendModel @Inject constructor( initAppCurrency() } - override fun onNavigationResult(navigationUM: NavigationUM) { - uiState.update { it.copy(navigationUM = navigationUM) } - } - override fun onResult(nftSendUM: NFTSendUM) { uiState.value = nftSendUM } @@ -119,8 +113,8 @@ internal class NFTSendModel @Inject constructor( uiState.update { it.copy(destinationUM = destinationUM) } } - override fun onBackClick() { - if (currentRouteFlow.value == CommonSendRoute.ConfirmSuccess) { + override fun onBackClick(currentRoute: Route) { + if (currentRoute == CommonSendRoute.ConfirmSuccess) { modelScope.launch { nftSendSuccessTrigger.triggerSuccessNFTSend() } @@ -128,14 +122,14 @@ internal class NFTSendModel @Inject constructor( router.pop() } - override fun onNextClick() { - if (currentRouteFlow.value.isEditMode) { - onBackClick() + override fun onNextClick(currentRoute: Route) { + if ((currentRoute as? CommonSendRoute)?.isEditMode == true) { + onBackClick(currentRoute) } else { - when (currentRouteFlow.value) { + when (currentRoute) { is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm) CommonSendRoute.Confirm -> router.replaceAll(CommonSendRoute.ConfirmSuccess) - else -> onBackClick() + else -> onBackClick(currentRoute) } } } @@ -241,6 +235,5 @@ internal class NFTSendModel @Inject constructor( destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, ) } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt index 2642e9da0d..2384aa7724 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt @@ -1,15 +1,27 @@ package com.tangem.features.send.sendnft.success +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.navigationButtons.DoneButtons +import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -19,24 +31,22 @@ import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams -import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.impl.R import com.tangem.features.send.sendnft.success.model.NFTSendSuccessModel import com.tangem.features.send.sendnft.success.ui.NFTSendSuccessContent import com.tangem.features.send.sendnft.ui.state.NFTSendUM -import com.tangem.features.send.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow internal class NFTSendSuccessComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: Params, + @Assisted private val params: Params, nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: NFTSendSuccessModel = getOrCreateModel(params = params) @@ -69,6 +79,19 @@ internal class NFTSendSuccessComponent @AssistedInject constructor( onClick = {}, ) + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + onBackClick = { + model.onBackClick() + router.pop() + }, + backIconRes = R.drawable.ic_close_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -80,11 +103,40 @@ internal class NFTSendSuccessComponent @AssistedInject constructor( ) } + @Composable + override fun Footer() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + DoneButtons( + (NavigationButton( + textReference = resourceReference(R.string.common_explore), + iconRes = R.drawable.ic_web_24, + onClick = model::onExploreClick, + ) to NavigationButton( + textReference = resourceReference(R.string.common_share), + iconRes = R.drawable.ic_share_24, + onClick = model::onShareClick, + )).takeIf { params.txUrl.isNotEmpty() }, + ) + PrimaryButton( + text = stringResourceSafe(R.string.common_close), + onClick = router::pop, + modifier = Modifier.fillMaxWidth(), + ) + } + } + data class Params( val nftSendUMFlow: StateFlow, val analyticsCategoryName: String, val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, - val currentRoute: Flow, val cryptoCurrencyStatus: CryptoCurrencyStatus, val userWallet: UserWallet, val nftAsset: NFTAsset, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt index 5c9061f7f8..7ebfaa2317 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt @@ -1,27 +1,17 @@ package com.tangem.features.send.sendnft.success.model import androidx.compose.runtime.Stable -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent -import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import javax.inject.Inject @Stable @@ -37,71 +27,23 @@ internal class NFTSendSuccessModel @Inject constructor( val uiState = params.nftSendUMFlow - init { - configConfirmSuccessNavigation() + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = params.analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = true, + ), + ) } - private fun configConfirmSuccessNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).filter { (state, route) -> - // Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh - // lambdas every time, so the SendUM written back via callback.onResult is never equal to the - // previous one — without this guard the combine re-triggers itself endlessly and the success - // screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY]. - route is CommonSendRoute.ConfirmSuccess && - (state.navigationUM as? NavigationUM.Content)?.source != - CommonSendRoute.ConfirmSuccess.javaClass.simpleName - }.onEach { (state, _) -> - params.callback.onResult( - state.copy( - navigationUM = NavigationUM.Content( - source = CommonSendRoute.ConfirmSuccess.javaClass.simpleName, - title = stringReference(""), - subtitle = null, - backIconRes = R.drawable.ic_close_24, - backIconClick = { - analyticsEventHandler.send( - CommonSendAnalyticEvents.CloseButtonClicked( - categoryName = params.analyticsCategoryName, - source = SendScreenSource.Confirm, - isFromSummary = true, - isValid = true, - ), - ) - params.callback.onBackClick() - }, - primaryButton = NavigationButton( - textReference = resourceReference(R.string.common_close), - iconRes = null, - isEnabled = true, - isHapticClick = false, - onClick = params.callback::onBackClick, - ), - prevButton = null, - secondaryPairButtonsUM = NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - onClick = ::onExploreClick, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - onClick = ::onShareClick, - ), - ), - ), - ) - }.launchIn(modelScope) - } - - private fun onExploreClick() { + fun onExploreClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) urlOpener.openUrl(params.txUrl) } - private fun onShareClick() { + fun onShareClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) shareManager.shareText(params.txUrl) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt index aa4bd4ceff..8ca229c671 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2 import com.tangem.core.ui.components.Fade import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.transactions.TransactionDoneTitle @@ -23,8 +22,8 @@ import com.tangem.features.nft.component.NFTDetailsBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.common.ui.FeeBlockSuccess import com.tangem.features.send.common.ui.state.ConfirmUM -import com.tangem.features.send.sendnft.ui.state.NFTSendUM import com.tangem.features.send.impl.R +import com.tangem.features.send.sendnft.ui.state.NFTSendUM import kotlinx.coroutines.delay @Composable @@ -52,30 +51,20 @@ internal fun NFTSendSuccessContent( label = "Animate success content", modifier = modifier, ) { - Column { - Box( - modifier = Modifier - .weight(1f) - .background(TangemTheme.colors.background.tertiary), - ) { - SuccessContent( - nftSendUM = nftSendUM, - nftDetailsBlockComponent = nftDetailsBlockComponent, - destinationBlockComponent = destinationBlockComponent, - modifier = Modifier.fillMaxHeight(), - ) - Fade( - modifier = Modifier.align(Alignment.BottomCenter), - backgroundColor = TangemTheme.colors.background.tertiary, - ) - } - NavigationButtonsBlockV2( - navigationUM = nftSendUM.navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors.background.tertiary), + ) { + SuccessContent( + nftSendUM = nftSendUM, + nftDetailsBlockComponent = nftDetailsBlockComponent, + destinationBlockComponent = destinationBlockComponent, + modifier = Modifier.fillMaxHeight(), + ) + Fade( + modifier = Modifier.align(Alignment.BottomCenter), + backgroundColor = TangemTheme.colors.background.tertiary, ) } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt index 83701052e9..7dd0e5fcdb 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt @@ -1,13 +1,11 @@ package com.tangem.features.send.sendnft.ui.state -import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.ui.state.ConfirmUM internal data class NFTSendUM( val destinationUM: DestinationUM, val feeSelectorUM: FeeSelectorUM, val confirmUM: ConfirmUM, - val navigationUM: NavigationUM, ) \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt new file mode 100644 index 0000000000..46eb6327b7 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt @@ -0,0 +1,137 @@ +package com.tangem.features.send.sendnft.model + +import arrow.core.left +import arrow.core.right +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.errors.GetUserWalletError +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.nft.entity.NFTSendSuccessTrigger +import com.tangem.features.send.api.NFTSendComponent +import com.tangem.features.send.common.CommonSendRoute +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test + +/** + * Guards the NFT-send navigation refactor: [NFTSendModel.onBackClick] / [NFTSendModel.onNextClick] now + * receive the active [com.tangem.core.decompose.navigation.Route] as a parameter (instead of reading an + * internal `currentRouteFlow`). Routing is synchronous, so those assertions run without advancing; the + * one async effect — firing `NFTSendSuccessTrigger.triggerSuccessNFTSend()` when leaving the success + * screen — is verified after `advanceUntilIdle()`. + * + * `init {}` collectors are kept harmless under `advanceUntilIdle()` by stubbing the wallet lookup to the + * not-found branch (so no further currency/account fetching is reached) and the app-currency lookup. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class NFTSendModelNavigationTest { + + private val router: Router = mockk(relaxed = true) + private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true) + private val nftSendSuccessTrigger: NFTSendSuccessTrigger = mockk(relaxed = true) + + private var model: NFTSendModel? = null + + @AfterEach + fun tearDown() { + model?.onDestroy() + model = null + } + + @Test + fun `GIVEN destination step WHEN onNextClick THEN pushes confirm`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.Destination(isEditMode = false)) + + verify(exactly = 1) { router.push(CommonSendRoute.Confirm) } + } + + @Test + fun `GIVEN edit-mode destination WHEN onNextClick THEN pops instead of advancing`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.Destination(isEditMode = true)) + + verify(exactly = 1) { router.pop() } + verify(exactly = 0) { router.push(any()) } + } + + @Test + fun `GIVEN destination step WHEN onBackClick THEN pops without firing success trigger`() = runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.Destination(isEditMode = false)) + advanceUntilIdle() + + verify(exactly = 1) { router.pop() } + coVerify(exactly = 0) { nftSendSuccessTrigger.triggerSuccessNFTSend() } + } + + @Test + fun `GIVEN confirm-success route WHEN onBackClick THEN fires success trigger and pops`() = runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.ConfirmSuccess) + advanceUntilIdle() + + coVerify(exactly = 1) { nftSendSuccessTrigger.triggerSuccessNFTSend() } + verify(exactly = 1) { router.pop() } + } + + private fun createModel(testScope: TestScope): NFTSendModel { + every { getUserWalletUseCase(any()) } returns GetUserWalletError.UserWalletNotFound.left() + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() + + val params = NFTSendComponent.Params( + userWalletId = UserWalletId(stringValue = "0123456789"), + nftAsset = mockk(relaxed = true), + nftCollectionName = "Test Collection", + ) + return NFTSendModel( + paramsContainer = MutableParamsContainer(value = params), + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getUserWalletUseCase = getUserWalletUseCase, + multiWalletCryptoCurrenciesSupplier = mockk(relaxed = true), + getFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true), + createNFTTransferTransactionUseCase = mockk(relaxed = true), + getFeeUseCase = mockk(relaxed = true), + saveBlockchainErrorUseCase = mockk(relaxed = true), + getWalletMetaInfoUseCase = mockk(relaxed = true), + sendFeedbackEmailUseCase = mockk(relaxed = true), + alertFactory = mockk(relaxed = true), + nftSendSuccessTrigger = nftSendSuccessTrigger, + isAccountsModeEnabledUseCase = mockk(relaxed = true), + getAccountCurrencyStatusUseCase = mockk(relaxed = true), + analyticsEventHandler = mockk(relaxed = true), + ).also { model = it } + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } +} \ No newline at end of file