From b9a5220984885e44c9291699184a0bdc151ffa6b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 12:00:16 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../features/send/common/ui/SendContent.kt | 99 --------- .../send/common/ui/SendModularContent.kt | 68 ++++++ .../send/common/utils/SendRouteUtils.kt | 28 --- .../send/send/DefaultSendComponent.kt | 199 +++++++----------- .../send/send/confirm/SendConfirmComponent.kt | 57 ++++- .../send/confirm/model/SendConfirmModel.kt | 66 ++---- .../features/send/send/model/SendModel.kt | 32 ++- .../success/SendConfirmSuccessComponent.kt | 107 +++++++++- .../success/model/SendConfirmSuccessModel.kt | 84 +------- .../success/ui/SendConfirmSuccessContent.kt | 9 - .../features/send/send/ui/state/SendUM.kt | 4 +- .../send/model/SendModelNavigationTest.kt | 198 +++++++++++++++++ 12 files changed, 526 insertions(+), 425 deletions(-) delete mode 100644 features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt delete mode 100644 features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt deleted file mode 100644 index a0c00f2d22..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.tangem.features.send.common.ui - -import androidx.compose.animation.* -import androidx.compose.foundation.background -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.slide -import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation -import com.arkivanov.decompose.router.stack.ChildStack -import com.tangem.common.ui.footers.SendingText -import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2 -import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.core.ui.components.Fade -import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.common.ui.state.ConfirmUM - -@Composable -internal fun SendContent( - navigationUM: NavigationUM, - confirmUM: ConfirmUM, - stackState: ChildStack, -) { - Column( - modifier = Modifier - .background(color = TangemTheme.colors.background.tertiary) - .fillMaxSize() - .imePadding() - .systemBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SendAppBar(navigationUM = navigationUM) - Children( - stack = stackState, - animation = stackAnimation { child -> - when (child.configuration) { - is CommonSendRoute.ConfirmSuccess -> fade(minAlpha = 1.0f) - is CommonSendRoute.Confirm -> fade() - else -> slide() - } - }, - modifier = Modifier.weight(1f), - ) { - Box(modifier = Modifier.fillMaxHeight()) { - it.instance.Content(Modifier.fillMaxSize(1f)) - - if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) { - Fade( - backgroundColor = TangemTheme.colors.background.tertiary, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } - } - } - if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) { - Column { - AnimatedVisibility( - visible = stackState.active.configuration == CommonSendRoute.Confirm, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), - ) { - SendingText(footerText = (confirmUM as? ConfirmUM.Content)?.sendingFooter ?: TextReference.EMPTY) - } - NavigationButtonsBlockV2( - navigationUM = navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - ) - } - } - } -} - -@Composable -private fun SendAppBar(navigationUM: NavigationUM) { - val navigationUMContent = navigationUM as? NavigationUM.Content ?: return - AppBarWithBackButtonAndIcon( - text = navigationUMContent.title.resolveReference(), - subtitle = navigationUMContent.subtitle?.resolveReference(), - onBackClick = navigationUMContent.backIconClick, - onIconClick = navigationUMContent.additionalIconClick, - backIconRes = navigationUMContent.backIconRes, - iconRes = navigationUMContent.additionalIconRes, - backgroundColor = TangemTheme.colors.background.tertiary, - modifier = Modifier.height(TangemTheme.dimens.size56), - ) -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt new file mode 100644 index 0000000000..7d5efc908c --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt @@ -0,0 +1,68 @@ +package com.tangem.features.send.common.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.slide +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.components.Fade +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.common.CommonSendRoute + +/** + * Shared pull-based host for the regular Send and NFT Send flows (both over [CommonSendRoute]). Renders the + * ACTIVE child's [ComposableModularContentComponent.Title] / [ComposableModularContentComponent.Footer] slots + * in place (matching the previous in-place app-bar/footer behavior), while keeping the per-route slide/fade + * Decompose [Children] animation for the Content region (and the bottom `Fade` gradient, hidden on + * `ConfirmSuccess`, exactly as the previous `SendContent`). + * + * Each step's `Footer()` owns its own bottom block (Confirm reveals `SendingText`; Success/Empty render + * nothing), so the host no longer special-cases routes for the footer. + */ +@Composable +internal fun SendModularContent(stackState: ChildStack) { + Column( + modifier = Modifier + .background(color = TangemTheme.colors.background.tertiary) + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + stackState.active.instance.Title() + Children( + stack = stackState, + animation = stackAnimation { child -> + when (child.configuration) { + is CommonSendRoute.ConfirmSuccess -> fade(minAlpha = 1.0f) + is CommonSendRoute.Confirm -> fade() + else -> slide() + } + }, + modifier = Modifier.weight(1f), + ) { + Box(modifier = Modifier.fillMaxHeight()) { + it.instance.Content(Modifier.fillMaxSize(1f)) + + if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) { + Fade( + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } + } + stackState.active.instance.Footer() + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt deleted file mode 100644 index 8e1878cadc..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.features.send.common.utils - -import com.arkivanov.decompose.router.stack.ChildStack -import com.arkivanov.decompose.value.Value -import com.tangem.core.decompose.navigation.Router -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.send.common.CommonSendRoute - -/** - * Workaround to try fix duplicate route crash - */ -internal fun Router.safeNextClick( - currentRoute: CommonSendRoute, - nextRoute: CommonSendRoute, - childStack: Value>, - popBack: () -> Unit, -) { - if (currentRoute.isEditMode) { - popBack() - } else { - val isAlreadyInStack = childStack.value.items.any { it.configuration == nextRoute } - if (isAlreadyInStack) { - popTo(nextRoute) - } else { - push(nextRoute) - } - } -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt index a44f29903c..b4fe92d902 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt @@ -1,15 +1,9 @@ package com.tangem.features.send.send import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment 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 @@ -17,49 +11,45 @@ import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.ObserveLifecycleMode import com.arkivanov.decompose.value.subscribe import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child 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.decompose.EmptyComposableBottomSheetComponent import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.derivationIndex import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.amount.AmountRoute import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams +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.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent 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.send.confirm.SendConfirmComponent import com.tangem.features.send.send.model.SendModel import com.tangem.features.send.send.success.SendConfirmSuccessComponent -import com.tangem.features.send.subcomponents.amount.DefaultSendAmountComponent -import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch -@Suppress("LargeClass") +@Suppress("LongParameterList") internal class DefaultSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SendComponent.Params, private val analyticsEventHandler: AnalyticsEventHandler, private val amountComponentFactory: SendAmountComponent.Factory, + private val destinationComponentFactory: SendDestinationComponent.Factory, + private val sendConfirmSuccessComponent: SendConfirmSuccessComponent.Factory, private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, - private val sendDestinationComponentFactory: SendDestinationComponent.Factory, ) : SendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -93,48 +83,45 @@ internal class DefaultSendComponent @AssistedInject constructor( lifecycle = lifecycle, mode = ObserveLifecycleMode.CREATE_DESTROY, ) { stack -> - componentScope.launch { - when (val activeComponent = stack.active.instance) { - is SendConfirmComponent -> { - val fromCurrency = params.currency - val fromDerivationIndex = model.accountFlow.value?.derivationIndex?.value - .takeIf { model.isAccountModeFlow.value } - analyticsEventHandler.send( - CommonSendAnalyticEvents.ConfirmationScreenOpened( - categoryName = model.analyticCategoryName, - source = model.analyticsSendSource, - sendBlockchain = fromCurrency.network.name, - sendToken = fromCurrency.symbol, - fromDerivationIndex = fromDerivationIndex, - toDerivationIndex = null, - type = model.consumeEntryType(), - ), - ) - if (model.currentRoute.value.isEditMode) { - activeComponent.updateState(model.uiState.value) - } - } - is DefaultSendAmountComponent -> { - analyticsEventHandler.send( - CommonSendAnalyticEvents.AmountScreenOpened( - categoryName = model.analyticCategoryName, - source = model.analyticsSendSource, - type = model.consumeEntryType(), - ), - ) - activeComponent.updateState(model.uiState.value.amountUM) - } - is SendDestinationComponent -> { - analyticsEventHandler.send( - CommonSendAnalyticEvents.AddressScreenOpened( - categoryName = model.analyticCategoryName, - source = model.analyticsSendSource, - ), - ) - activeComponent.updateState(model.uiState.value.destinationUM) + when (val activeComponent = stack.active.instance) { + is SendConfirmComponent -> { + val fromCurrency = params.currency + val fromDerivationIndex = model.accountFlow.value?.derivationIndex?.value + .takeIf { model.isAccountModeFlow.value } + analyticsEventHandler.send( + CommonSendAnalyticEvents.ConfirmationScreenOpened( + categoryName = model.analyticCategoryName, + source = model.analyticsSendSource, + sendBlockchain = fromCurrency.network.name, + sendToken = fromCurrency.symbol, + fromDerivationIndex = fromDerivationIndex, + toDerivationIndex = null, + type = model.consumeEntryType(), + ), + ) + if (childStack.value.active.configuration.isEditMode) { + activeComponent.updateState(model.uiState.value) } } - model.currentRoute.emit(stack.active.configuration) + is SendAmountComponent -> { + analyticsEventHandler.send( + CommonSendAnalyticEvents.AmountScreenOpened( + categoryName = model.analyticCategoryName, + source = model.analyticsSendSource, + type = model.consumeEntryType(), + ), + ) + activeComponent.updateState(model.uiState.value.amountUM) + } + is SendDestinationComponent -> { + analyticsEventHandler.send( + CommonSendAnalyticEvents.AddressScreenOpened( + categoryName = model.analyticCategoryName, + source = model.analyticsSendSource, + ), + ) + activeComponent.updateState(model.uiState.value.destinationUM) + } } } } @@ -142,34 +129,31 @@ internal class DefaultSendComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val stackState by childStack.subscribeAsState() - val state by model.uiState.collectAsStateWithLifecycle() - BackHandler( - onBack = { - (state.navigationUM as? NavigationUM.Content)?.backIconClick() ?: onChildBack() - }, - ) - SendContent( - navigationUM = state.navigationUM, - confirmUM = state.confirmUM, - stackState = stackState, - ) + BackHandler(onBack = ::onChildBack) + SendModularContent(stackState = stackState) } - private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { - CommonSendRoute.Empty -> getStubComponent() - is CommonSendRoute.Destination -> getDestinationComponent(factoryContext) - is CommonSendRoute.Amount -> getAmountComponent(factoryContext) + private fun createChild( + route: CommonSendRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = when (route) { + CommonSendRoute.Empty -> ComposableModularContentComponent.EMPTY + is CommonSendRoute.Destination -> getDestinationComponent(route, factoryContext) + is CommonSendRoute.Amount -> getAmountComponent(route, factoryContext) is CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) is CommonSendRoute.ConfirmSuccess -> getConfirmSuccessComponent(factoryContext) } - private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent = - sendDestinationComponentFactory.create( + private fun getDestinationComponent( + route: DestinationRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { + return destinationComponentFactory.create( context = factoryContext, params = SendDestinationComponentParams.DestinationParams( state = model.uiState.value.destinationUM, - currentRoute = model.currentRoute.filterIsInstance(), + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, analyticsSendSource = model.analyticsSendSource, @@ -179,13 +163,17 @@ internal class DefaultSendComponent @AssistedInject constructor( callback = model, ), ) + } - private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getAmountComponent( + route: AmountRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { return amountComponentFactory.create( context = factoryContext, params = SendAmountComponentParams.AmountParams( state = model.uiState.value.amountUM, - currentRoute = model.currentRoute.filterIsInstance(), + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, appCurrency = model.appCurrency, @@ -201,7 +189,7 @@ internal class DefaultSendComponent @AssistedInject constructor( ) } - private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { return if (model.isAvailableForSend) { val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value val feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatusFlow.value @@ -210,7 +198,6 @@ internal class DefaultSendComponent @AssistedInject constructor( params = SendConfirmComponent.Params( state = model.uiState.value, userWallet = model.userWallet, - currentRoute = model.currentRoute, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -233,79 +220,43 @@ internal class DefaultSendComponent @AssistedInject constructor( ) } else { model.showAlertError() - getStubComponent() + ComposableModularContentComponent.EMPTY } } - private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { val state = model.uiState.value val sendAmount = (state.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value val txUrl = (state.confirmUM as? ConfirmUM.Success)?.txUrl - val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value if (sendAmount == null || destinationAddress == null || txUrl == null ) { model.showAlertError() - return getStubComponent() + return ComposableModularContentComponent.EMPTY } - val destinationBlockComponent = - DefaultSendDestinationBlockComponent( - appComponentContext = child("sendConfirmDestinationBlock"), - params = SendDestinationComponentParams.DestinationBlockParams( - state = model.uiState.value.destinationUM, - analyticsCategoryName = model.analyticCategoryName, - analyticsSendSource = model.analyticsSendSource, - userWalletId = model.userWallet.walletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - blockClickEnableFlow = MutableStateFlow(true), - predefinedValues = model.predefinedValues, - isAddContactAvailable = true, - ), - onResult = { }, - onClick = {}, - ) - - return SendConfirmSuccessComponent( + return sendConfirmSuccessComponent.create( appComponentContext = factoryContext, params = SendConfirmSuccessComponent.Params( sendUMFlow = model.uiState, - destinationBlockComponent = destinationBlockComponent, + userWalletId = params.userWalletId, + cryptoCurrency = params.currency, + predefinedValues = model.predefinedValues, analyticsCategoryName = model.analyticCategoryName, - currentRoute = model.currentRoute, txUrl = txUrl, callback = model, ), ) } - private fun getStubComponent() = StubComponent() - - class StubComponent : ComposableContentComponent { - @Composable - override fun Content(modifier: Modifier) { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.padding(TangemTheme.dimens.spacing12), - color = TangemTheme.colors.icon.primary1, - strokeWidth = TangemTheme.dimens.size2, - ) - } - } - } - private fun onChildBack() { val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyStack = childStack.value.backStack.isEmpty() val isSuccess = model.uiState.value.confirmUM is ConfirmUM.Success - val isStubComponent = childStack.value.active.instance is StubComponent + val isStubComponent = childStack.value.active.instance == EmptyComposableBottomSheetComponent val isSendingInProgress = (model.uiState.value.confirmUM as? ConfirmUM.Content)?.isSending == true val isPopSend = isEmptyRoute || isEmptyStack || isSuccess || isStubComponent diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt index 6e369f2e78..d13114d87c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt @@ -1,15 +1,26 @@ package com.tangem.features.send.send.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.stringResourceSafe +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 @@ -18,17 +29,17 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams 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.notifications.SendNotificationsComponent -import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.send.confirm.model.SendConfirmModel import com.tangem.features.send.send.confirm.ui.SendConfirmContent import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent -import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent import com.tangem.utils.extensions.orZero @@ -38,7 +49,7 @@ internal class SendConfirmComponent( appComponentContext: AppComponentContext, params: Params, feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SendConfirmModel = getOrCreateModel(params = params) @@ -133,6 +144,20 @@ internal class SendConfirmComponent( model.updateState(state) } + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + text = stringResourceSafe(R.string.common_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() @@ -148,6 +173,29 @@ internal class SendConfirmComponent( ) } + @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(state.confirmUM), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + } + data class Params( val state: SendUM, val analyticsCategoryName: String, @@ -161,7 +209,6 @@ internal class SendConfirmComponent( val isAccountModeFlow: StateFlow, val appCurrency: AppCurrency, val callback: ModelCallback, - val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, val predefinedValues: PredefinedValues, val onLoadFee: suspend () -> Either, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt index 48dabef006..d30b1aaa76 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt @@ -10,7 +10,6 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.models.AmountState 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 @@ -160,7 +159,6 @@ internal class SendConfirmModel @Inject constructor( init { updateAmountSubtractAvailability() - configConfirmNavigation() subscribeOnNotificationsUpdateTrigger() subscribeOnCheckFeeResultUpdates() initialState() @@ -303,6 +301,17 @@ internal class SendConfirmModel @Inject constructor( } } + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = uiState.value.confirmUM.isPrimaryButtonEnabled, + ), + ) + } + private fun initialState() { val confirmUM = uiState.value.confirmUM @@ -543,58 +552,7 @@ internal class SendConfirmModel @Inject constructor( return isHighNetworkFeeUseCase(feeCurrency, feeAmount) } - @Suppress("LongMethod") - private fun configConfirmNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).filter { - it.second is CommonSendRoute.Confirm - }.onEach { (state, _) -> - val confirmUM = state.confirmUM - params.callback.onResult( - state.copy( - navigationUM = NavigationUM.Content( - source = CommonSendRoute.Confirm.javaClass.simpleName, - title = resourceReference(id = R.string.common_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, - ) - ).takeIf { confirmUM is ConfirmUM.Success }, - ), - ), - ) - }.launchIn(modelScope) - } - - private fun primaryButtonUM(): NavigationButton { - val confirmUM = uiState.value.confirmUM + fun primaryButtonUM(confirmUM: ConfirmUM): NavigationButton { val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending val isHoldToConfirm = userWallet.isHotWallet && isContent diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt index 643b2cfae1..29bbb27bd4 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt @@ -7,13 +7,13 @@ import arrow.core.left import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.models.AmountState -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.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseBigDecimalOrNull @@ -123,8 +123,6 @@ internal class SendModel @Inject constructor( CommonSendRoute.Empty } - val currentRoute = MutableStateFlow(initialRoute) - val cryptoCurrencyStatusFlow: StateFlow field = MutableStateFlow( CryptoCurrencyStatus( @@ -149,7 +147,7 @@ internal class SendModel @Inject constructor( return cryptoCurrencyStatus.isAvailableForSend() && feeCryptoCurrencyStatus.isAvailableForSend() } - val isUnavailableForSend: Boolean + private val isUnavailableForSend: Boolean get() { val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value val feeCryptoCurrencyStatus = feeCryptoCurrencyStatusFlow.value @@ -179,10 +177,6 @@ internal class SendModel @Inject constructor( initAppCurrency() } - override fun onNavigationResult(navigationUM: NavigationUM) { - uiState.update { it.copy(navigationUM = navigationUM) } - } - override fun onDestinationResult(destinationUM: DestinationUM) { uiState.update { it.copy(destinationUM = destinationUM) } } @@ -196,9 +190,9 @@ internal class SendModel @Inject constructor( uiState.update { sendUM } } - override fun onBackClick() { - when (val route = currentRoute.value) { - is CommonSendRoute.Amount -> if (!route.isEditMode) { + override fun onBackClick(currentRoute: Route) { + when (currentRoute) { + is CommonSendRoute.Amount -> if (!currentRoute.isEditMode) { analyticsEventHandler.send( CommonSendAnalyticEvents.CloseButtonClicked( categoryName = analyticCategoryName, @@ -208,7 +202,7 @@ internal class SendModel @Inject constructor( ), ) } - is CommonSendRoute.Destination -> if (!route.isEditMode) { + is CommonSendRoute.Destination -> if (!currentRoute.isEditMode) { analyticsEventHandler.send( CommonSendAnalyticEvents.CloseButtonClicked( categoryName = analyticCategoryName, @@ -224,11 +218,11 @@ internal class SendModel @Inject constructor( router.pop() } - override fun onNextClick() { - if (currentRoute.value.isEditMode) { - onBackClick() + override fun onNextClick(currentRoute: Route) { + if ((currentRoute as? CommonSendRoute)?.isEditMode == true) { + onBackClick(currentRoute) } else { - when (currentRoute.value) { + when (currentRoute) { is CommonSendRoute.Amount -> { val nextRoute = if (predefinedValues.isFromMainScreenQr) { CommonSendRoute.Confirm @@ -239,7 +233,7 @@ internal class SendModel @Inject constructor( } is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm) CommonSendRoute.Confirm -> router.push(CommonSendRoute.ConfirmSuccess) - else -> onBackClick() + else -> router.pop() } } } @@ -263,7 +257,6 @@ internal class SendModel @Inject constructor( feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, confirmData = null, - navigationUM = NavigationUM.Empty, ) } router.popTo(CommonSendRoute.Amount(isEditMode = false)) @@ -442,7 +435,7 @@ internal class SendModel @Inject constructor( cryptoCurrencyStatusFlow, feeCryptoCurrencyStatusFlow, ) { cryptoCurrencyStatus, _ -> - if (!isAvailableForSend || currentRoute.value != initialRoute) { + if (!isAvailableForSend) { if (isUnavailableForSend) showAlertError() return@combine } @@ -548,7 +541,6 @@ internal class SendModel @Inject constructor( cryptoCurrency = cryptoCurrency, ).transform(DestinationUM.Empty()), confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, confirmData = null, feeSelectorUM = FeeSelectorUM.Loading, ) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt index 314345341d..6890d5f4fd 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt @@ -1,27 +1,77 @@ package com.tangem.features.send.send.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.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.navigationButtons.DoneButtons +import com.tangem.common.ui.navigationButtons.NavigationButton 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.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +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.common.CommonSendRoute +import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.impl.R import com.tangem.features.send.send.success.model.SendConfirmSuccessModel import com.tangem.features.send.send.success.ui.SendConfirmSuccessContent import com.tangem.features.send.send.ui.state.SendUM -import kotlinx.coroutines.flow.Flow +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -internal class SendConfirmSuccessComponent( - appComponentContext: AppComponentContext, - params: Params, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +internal class SendConfirmSuccessComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: Params, + destinationBlockComponentFactory: SendDestinationBlockComponent.Factory, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SendConfirmSuccessModel = getOrCreateModel(params = params) - private val destinationBlockComponent: SendDestinationBlockComponent = params.destinationBlockComponent + private val destinationBlockComponent: SendDestinationBlockComponent = destinationBlockComponentFactory.create( + context = child("sendConfirmDestinationBlock"), + params = SendDestinationComponentParams.DestinationBlockParams( + state = model.uiState.value.destinationUM, + analyticsCategoryName = params.analyticsCategoryName, + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + blockClickEnableFlow = MutableStateFlow(true), + predefinedValues = params.predefinedValues, + isAddContactAvailable = true, + ), + onResult = {}, + 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) { @@ -32,11 +82,43 @@ internal class SendConfirmSuccessComponent( ) } + @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 sendUMFlow: StateFlow, - val destinationBlockComponent: SendDestinationBlockComponent, + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, val analyticsCategoryName: String, - val currentRoute: Flow, + val predefinedValues: PredefinedValues, val txUrl: String, val callback: ModelCallback, ) @@ -44,4 +126,9 @@ internal class SendConfirmSuccessComponent( interface ModelCallback { fun onResult(sendUM: SendUM) } + + @AssistedFactory + interface Factory { + fun create(appComponentContext: AppComponentContext, params: Params): SendConfirmSuccessComponent + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt index f13a80be84..c7854d8fef 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt @@ -1,28 +1,17 @@ package com.tangem.features.send.send.success.model import androidx.compose.runtime.Stable -import com.tangem.common.routing.AppRouter -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.success.SendConfirmSuccessComponent import com.tangem.features.send.send.ui.state.SendUM -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 @@ -31,7 +20,6 @@ internal class SendConfirmSuccessModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, - private val appRouter: AppRouter, private val urlOpener: UrlOpener, private val shareManager: ShareManager, ) : Model() { @@ -39,73 +27,23 @@ internal class SendConfirmSuccessModel @Inject constructor( private val _uiState = params.sendUMFlow val uiState = _uiState - 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, - ), - ) - appRouter.pop() - }, - primaryButton = NavigationButton( - textReference = resourceReference(R.string.common_close), - iconRes = null, - isEnabled = true, - isHapticClick = false, - onClick = { - appRouter.pop() - }, - ), - 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, - )).takeIf { params.txUrl.isNotEmpty() }, - ), - ), - ) - }.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/send/success/ui/SendConfirmSuccessContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt index 598a87cf9d..edfa9bc05a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.tangem.common.ui.amountScreen.ui.AmountBlock -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 @@ -63,14 +62,6 @@ internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent backgroundColor = TangemTheme.colors.background.tertiary, ) } - NavigationButtonsBlockV2( - navigationUM = sendUM.navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - ) } } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt index 0ebe64eceb..70c2599c4c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt @@ -1,9 +1,8 @@ package com.tangem.features.send.send.ui.state import com.tangem.common.ui.amountScreen.models.AmountState -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 import com.tangem.features.send.send.confirm.model.ConfirmData @@ -12,6 +11,5 @@ internal data class SendUM( val destinationUM: DestinationUM, val feeSelectorUM: FeeSelectorUM, val confirmUM: ConfirmUM, - val navigationUM: NavigationUM, val confirmData: ConfirmData?, ) \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt new file mode 100644 index 0000000000..1b070f7492 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt @@ -0,0 +1,198 @@ +package com.tangem.features.send.send.model + +import arrow.core.right +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.features.send.api.SendComponent +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource +import com.tangem.features.send.common.CommonSendRoute +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.CloseButtonClicked as CloseButtonClickedEvent + +/** + * Guards the navigation refactor that moved the footer/app-bar actions out of the model into + * `DefaultSendComponent`. The model's [SendModel.onBackClick] / [SendModel.onNextClick] no longer read + * an internal `currentRoute` StateFlow — they receive the active [com.tangem.core.decompose.navigation.Route] + * as a parameter and decide routing/analytics from it. These are pure, synchronous decisions, so each + * method is asserted *before* advancing the scheduler; the model is then destroyed inside the test body + * so its `init {}` collectors (all `modelScope.launch`/`launchIn`, including an infinite status collector) + * are cancelled — never run — before `runTest`'s terminal advance. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class SendModelNavigationTest { + + private val router: Router = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true) + private val cryptoCurrency = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum) + + // region onNextClick + + @Test + fun `GIVEN manual entry on amount WHEN onNextClick THEN pushes destination`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.Amount(isEditMode = false)) + + verify(exactly = 1) { router.push(CommonSendRoute.Destination(isEditMode = false)) } + verify(exactly = 0) { router.push(CommonSendRoute.Confirm) } + model.onDestroy() + } + + @Test + fun `GIVEN main-screen QR predefined values WHEN onNextClick on amount THEN skips destination and pushes confirm`() = + runTest { + // Arrange — address-only predefined values resolve to a MAIN_SCREEN QrCode (isFromMainScreenQr = true). + val model = createModel(this, params = qrParams()) + + // Act + model.onNextClick(CommonSendRoute.Amount(isEditMode = false)) + + // Assert + verify(exactly = 1) { router.push(CommonSendRoute.Confirm) } + verify(exactly = 0) { router.push(CommonSendRoute.Destination(isEditMode = false)) } + model.onDestroy() + } + + @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) } + model.onDestroy() + } + + @Test + fun `GIVEN edit-mode route WHEN onNextClick THEN pops instead of advancing`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.Amount(isEditMode = true)) + + verify(exactly = 1) { router.pop() } + verify(exactly = 0) { router.push(any()) } + model.onDestroy() + } + + @Test + fun `GIVEN confirm-success route WHEN onNextClick THEN pops`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.ConfirmSuccess) + + verify(exactly = 1) { router.pop() } + verify(exactly = 0) { router.push(any()) } + model.onDestroy() + } + + // endregion + + // region onBackClick + + @Test + fun `GIVEN amount step not in edit mode WHEN onBackClick THEN sends amount close analytics and pops`() = runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.Amount(isEditMode = false)) + + verify(exactly = 1) { + analyticsEventHandler.send(match { it is CloseButtonClickedEvent && it.source == SendScreenSource.Amount }) + } + verify(exactly = 1) { router.pop() } + model.onDestroy() + } + + @Test + fun `GIVEN destination step not in edit mode WHEN onBackClick THEN sends address close analytics and pops`() = + runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.Destination(isEditMode = false)) + + verify(exactly = 1) { + analyticsEventHandler.send( + match { it is CloseButtonClickedEvent && it.source == SendScreenSource.Address }, + ) + } + verify(exactly = 1) { router.pop() } + model.onDestroy() + } + + @Test + fun `GIVEN edit-mode route WHEN onBackClick THEN pops without close analytics`() = runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.Amount(isEditMode = true)) + + verify(exactly = 0) { analyticsEventHandler.send(any()) } + verify(exactly = 1) { router.pop() } + model.onDestroy() + } + + // endregion + + private fun manualParams() = SendComponent.Params( + userWalletId = UserWalletId(stringValue = "0123456789"), + currency = cryptoCurrency, + ) + + private fun qrParams() = SendComponent.Params( + userWalletId = UserWalletId(stringValue = "0123456789"), + currency = cryptoCurrency, + destinationAddress = "0xRECIPIENT", + ) + + private fun createModel(testScope: TestScope, params: SendComponent.Params = manualParams()): SendModel { + // Runs synchronously in init {}; a relaxed Either would break getOrElse, so stub a Right. + every { listenToQrScanningUseCase(any()) } returns emptyFlow().right() + return SendModel( + paramsContainer = MutableParamsContainer(value = params), + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + getUserWalletUseCase = mockk(relaxed = true), + getFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true), + getSelectedAppCurrencyUseCase = mockk(relaxed = true), + listenToQrScanningUseCase = listenToQrScanningUseCase, + parseQrCodeUseCase = mockk(relaxed = true), + sendConfirmAlertFactory = mockk(relaxed = true), + saveBlockchainErrorUseCase = mockk(relaxed = true), + getWalletMetaInfoUseCase = mockk(relaxed = true), + sendFeedbackEmailUseCase = mockk(relaxed = true), + getBalanceHidingSettingsUseCase = mockk(relaxed = true), + createTransferTransactionUseCase = mockk(relaxed = true), + getFeeUseCase = mockk(relaxed = true), + getFeeForGaslessUseCase = mockk(relaxed = true), + getFeeForTokenUseCase = mockk(relaxed = true), + getAccountCurrencyStatusUseCase = mockk(relaxed = true), + isAccountsModeEnabledUseCase = mockk(relaxed = true), + sendAmountUpdateTrigger = mockk(relaxed = true), + analyticsEventHandler = analyticsEventHandler, + ) + } + + 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