Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 15:36:54 +03:00
commit b603ef3a22
61 changed files with 2076 additions and 1452 deletions

View file

@ -1,8 +1,10 @@
package com.tangem.features.send.api.subcomponents.amount
import com.tangem.core.decompose.navigation.Route
/**
* Common route for amount
*/
interface AmountRoute {
interface AmountRoute : Route {
val isEditMode: Boolean
}

View file

@ -3,10 +3,10 @@ package com.tangem.features.send.api.subcomponents.amount
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationModelCallback
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.domain.wallets.models.errors.GetUserWalletError
interface SendAmountComponent : ComposableContentComponent {
interface SendAmountComponent : ComposableModularContentComponent {
fun updateState(amountUM: AmountState)

View file

@ -9,7 +9,6 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
sealed class SendAmountComponentParams {
@ -39,7 +38,7 @@ sealed class SendAmountComponentParams {
override val accountFlow: StateFlow<Account?>,
override val isAccountModeFlow: StateFlow<Boolean>,
val callback: SendAmountComponent.ModelCallback,
val currentRoute: Flow<AmountRoute>,
val route: AmountRoute,
) : SendAmountComponentParams()
data class AmountBlockParams(

View file

@ -1,8 +1,10 @@
package com.tangem.features.send.api.subcomponents.destination
import com.tangem.core.decompose.navigation.Route
/**
* Common route for destination
*/
interface DestinationRoute {
interface DestinationRoute : Route {
val isEditMode: Boolean
}

View file

@ -2,10 +2,10 @@ package com.tangem.features.send.api.subcomponents.destination
import com.tangem.common.ui.navigationButtons.NavigationModelCallback
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
interface SendDestinationComponent : ComposableContentComponent {
interface SendDestinationComponent : ComposableModularContentComponent {
fun updateState(destinationUM: DestinationUM)

View file

@ -6,7 +6,6 @@ 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.entity.DestinationUM
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
sealed class SendDestinationComponentParams {
@ -26,7 +25,7 @@ sealed class SendDestinationComponentParams {
override val userWalletId: UserWalletId,
val title: TextReference,
val isBalanceHidingFlow: StateFlow<Boolean>,
val currentRoute: Flow<DestinationRoute>,
val route: DestinationRoute,
val callback: SendDestinationComponent.ModelCallback,
override val isAllowSelfSend: Boolean = false,
) : SendDestinationComponentParams()

View file

@ -16,7 +16,7 @@ internal sealed class CommonSendRoute : Route {
@Serializable
data object Confirm : CommonSendRoute() {
override val isEditMode: Boolean = true
override val isEditMode: Boolean = false
}
@Serializable

View file

@ -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<CommonSendRoute, ComposableContentComponent>,
) {
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),
)
}

View file

@ -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<CommonSendRoute, ComposableModularContentComponent>) {
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()
}
}

View file

@ -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<ChildStack<CommonSendRoute, ComposableContentComponent>>,
popBack: () -> Unit,
) {
if (currentRoute.isEditMode) {
popBack()
} else {
val isAlreadyInStack = childStack.value.items.any { it.configuration == nextRoute }
if (isAlreadyInStack) {
popTo(nextRoute)
} else {
push(nextRoute)
}
}
}

View file

@ -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<CommonSendRoute>()
@ -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<CommonSendRoute.Destination>(),
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<CommonSendRoute.Amount>(),
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

View file

@ -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<Boolean>,
val appCurrency: AppCurrency,
val callback: ModelCallback,
val currentRoute: Flow<CommonSendRoute>,
val isBalanceHidingFlow: StateFlow<Boolean>,
val predefinedValues: PredefinedValues,
val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,

View file

@ -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
@ -552,58 +561,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

View file

@ -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<CryptoCurrencyStatus>
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,
)

View file

@ -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<SendUM>,
val destinationBlockComponent: SendDestinationBlockComponent,
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val analyticsCategoryName: String,
val currentRoute: Flow<CommonSendRoute>,
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
}
}

View file

@ -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)
}

View file

@ -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,
),
)
}
}
}

View file

@ -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?,
)

View file

@ -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<CommonSendRoute>()
@ -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<CommonSendRoute.Destination>(),
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<CommonSendRoute.Confirm>(),
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<CommonSendRoute.ConfirmSuccess>(),
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()

View file

@ -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<CommonSendRoute.Confirm>,
val isBalanceHidingFlow: StateFlow<Boolean>,
val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
val onSendTransaction: () -> Unit,

View file

@ -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

View file

@ -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<CommonSendRoute>(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,
)
}

View file

@ -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<NFTSendUM>,
val analyticsCategoryName: String,
val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
val currentRoute: Flow<CommonSendRoute>,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val userWallet: UserWallet,
val nftAsset: NFTAsset,

View file

@ -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)
}

View file

@ -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,
)
}
}

View file

@ -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,
)

View file

@ -1,14 +1,23 @@
package com.tangem.features.send.subcomponents.amount
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.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.impl.R
import com.tangem.features.send.subcomponents.amount.model.SendAmountModel
import com.tangem.features.send.subcomponents.amount.ui.SendAmountContent
import dagger.assisted.Assisted
@ -24,6 +33,23 @@ internal class DefaultSendAmountComponent @AssistedInject constructor(
override fun updateState(amountUM: AmountState) = model.updateState(amountUM)
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
text = stringResourceSafe(R.string.send_amount_label),
onBackClick = {
params.callback.onBackClick(params.route)
},
backIconRes = if (params.route.isEditMode) {
R.drawable.ic_back_24
} else {
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()
@ -37,6 +63,30 @@ internal class DefaultSendAmountComponent @AssistedInject constructor(
)
}
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
PrimaryButton(
text = if (params.route.isEditMode) {
stringResourceSafe(R.string.common_continue)
} else {
stringResourceSafe(R.string.common_next)
},
enabled = state.isPrimaryButtonEnabled,
onClick = {
model.onAmountNext()
params.callback.onNextClick(params.route)
},
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
@AssistedFactory
interface Factory : SendAmountComponent.Factory {
override fun create(

View file

@ -10,8 +10,6 @@ import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
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
@ -29,14 +27,12 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.entity.isFromMainScreenQr
import com.tangem.features.send.api.subcomponents.amount.AmountRoute
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents.SelectedCurrencyType
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
@ -84,7 +80,6 @@ internal class SendAmountModel @Inject constructor(
private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull()
init {
configAmountNavigation()
initAppCurrency()
subscribeOnCryptoCurrencyStatusFlow()
subscribeOnAmountReduceByTriggerUpdates()
@ -104,6 +99,7 @@ internal class SendAmountModel @Inject constructor(
},
ifRight = { wallet ->
userWallet = wallet
setSendWithSwapAvailability()
},
)
}.launchIn(modelScope)
@ -274,9 +270,7 @@ internal class SendAmountModel @Inject constructor(
override fun onConvertToAnotherToken() {
val amountParams = params as? SendAmountComponentParams.AmountParams ?: return
modelScope.launch {
var isEditMode = false
amountParams.currentRoute.collect { route -> isEditMode = route.isEditMode }
if (isEditMode) {
if (amountParams.route.isEditMode) {
sendAmountAlertFactory.showResetSendingAlert {
params.callback.resetSendNavigation()
confirmConvertToToken()
@ -363,44 +357,6 @@ internal class SendAmountModel @Inject constructor(
)
}
private fun configAmountNavigation() {
val params = params as? SendAmountComponentParams.AmountParams ?: return
combine(
flow = uiState,
// Filter on the public AmountRoute interface (not the internal CommonSendRoute.Amount) so an
// external host (e.g. staking) that supplies its own AmountRoute is not silently dropped here.
flow2 = params.currentRoute.filterIsInstance<AmountRoute>(),
transform = { state, route -> state to route },
).onEach { (state, route) ->
setSendWithSwapAvailability()
params.callback.onNavigationResult(
NavigationUM.Content(
source = CommonSendRoute.Amount::class.java.simpleName,
title = resourceReference(R.string.send_amount_label),
subtitle = null,
backIconRes = if (route.isEditMode) {
R.drawable.ic_back_24
} else {
R.drawable.ic_close_24
},
backIconClick = params.callback::onBackClick,
primaryButton = NavigationButton(
textReference = if (route.isEditMode) {
resourceReference(R.string.common_continue)
} else {
resourceReference(R.string.common_next)
},
isEnabled = state.isPrimaryButtonEnabled,
onClick = {
onAmountNext()
params.callback.onNextClick()
},
),
),
)
}.launchIn(modelScope)
}
private fun setSendWithSwapAvailability() {
// Allowed only in multicurrency wallets
val isMultiCurrency = userWallet?.isMultiCurrency == true

View file

@ -1,8 +1,13 @@
package com.tangem.features.send.subcomponents.destination
import androidx.activity.compose.BackHandler
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.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
@ -14,9 +19,15 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
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.impl.R
import com.tangem.features.send.subcomponents.destination.model.SendDestinationModel
import com.tangem.features.send.subcomponents.destination.ui.SendDestinationContent
import dagger.assisted.Assisted
@ -68,6 +79,22 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor(
override fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM)
@Composable
override fun Title() {
BackHandler(onBack = model::onBackClick)
AppBarWithBackButtonAndIcon(
text = params.title.resolveReference(),
onBackClick = model::onBackClick,
backIconRes = if (params.route.isEditMode) {
R.drawable.ic_back_24
} else {
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()
@ -83,6 +110,30 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor(
selector.child?.instance?.BottomSheet()
}
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
PrimaryButton(
text = if (params.route.isEditMode) {
stringResourceSafe(R.string.common_continue)
} else {
stringResourceSafe(R.string.common_next)
},
enabled = state.isPrimaryButtonEnabled,
onClick = {
model.saveResult()
params.callback.onNextClick(params.route)
},
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
@AssistedFactory
interface Factory : SendDestinationComponent.Factory {
override fun create(

View file

@ -8,14 +8,11 @@ import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.entity.AddressBookOpenMode
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.decompose.navigation.Router
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
@ -49,8 +46,6 @@ import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.impl.R
import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory
import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents
@ -142,7 +137,6 @@ internal class SendDestinationModel @Inject constructor(
private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null)
init {
configDestinationNavigation()
subscribeOnQRScannerResult()
initialState()
resetContactOnEdit()
@ -153,15 +147,12 @@ internal class SendDestinationModel @Inject constructor(
private fun resetContactOnEdit() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return
params.currentRoute
.filter { it.isEditMode }
.onEach {
val content = uiState.value as? DestinationUM.Content ?: return@onEach
if (content.addressTextField.contactName != null) {
_uiState.update(SendDestinationContactTransformer(contact = null))
}
if (params.route.isEditMode) {
val content = uiState.value as? DestinationUM.Content ?: return
if (content.addressTextField.contactName != null) {
_uiState.update(SendDestinationContactTransformer(contact = null))
}
.launchIn(modelScope)
}
}
fun onContactClick(contact: MatchedContact) {
@ -259,7 +250,23 @@ internal class SendDestinationModel @Inject constructor(
)
}
private fun saveResult() {
fun onBackClick() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return
if (!params.route.isEditMode) {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Address,
isFromSummary = false,
isValid = uiState.value.isPrimaryButtonEnabled,
),
)
saveResult()
}
params.callback.onBackClick(params.route)
}
fun saveResult() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return
params.callback.onDestinationResult(uiState.value)
}
@ -490,60 +497,10 @@ internal class SendDestinationModel @Inject constructor(
private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) {
if (type.isAutoNext && isValidAddress && isValidMemo) {
saveResult()
(params as? SendDestinationComponentParams.DestinationParams)?.callback?.onNextClick()
(params as? SendDestinationComponentParams.DestinationParams)?.callback?.onNextClick(params.route)
}
}
@Suppress("LongMethod")
private fun configDestinationNavigation() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).onEach { (state, route) ->
params.callback.onNavigationResult(
NavigationUM.Content(
source = CommonSendRoute.Destination::class.java.simpleName,
title = params.title,
subtitle = null,
backIconRes = if (route.isEditMode) {
R.drawable.ic_back_24
} else {
R.drawable.ic_close_24
},
backIconClick = {
if (!route.isEditMode) {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Address,
isFromSummary = false,
isValid = state.isPrimaryButtonEnabled,
),
)
saveResult()
}
params.callback.onBackClick()
},
primaryButton = NavigationButton(
textReference = if (route.isEditMode) {
resourceReference(R.string.common_continue)
} else {
resourceReference(R.string.common_next)
},
isEnabled = state.isPrimaryButtonEnabled,
onClick = {
saveResult()
params.callback.onNextClick()
},
),
secondaryPairButtonsUM = null,
),
)
}.launchIn(modelScope)
}
private companion object {
const val RECENT_TX_SIZE = 100
const val RECENT_LOAD_DELAY = 500L

View file

@ -1,16 +1,21 @@
package com.tangem.features.send.send
import arrow.core.Either
import arrow.core.right
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
@ -21,55 +26,45 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.SendFeatureToggles
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger
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.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger
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.send.analytics.SendAnalyticHelper
import com.tangem.features.send.send.confirm.SendConfirmComponent
import com.tangem.features.send.send.confirm.model.SendConfirmModel
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.send.testDispatcherProvider
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.qrscanning.models.SourceType
import arrow.core.right
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
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.model.SendModel
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.testDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
@ -89,7 +84,8 @@ internal abstract class SendModelTestBase {
protected val router: Router = mockk(relaxed = true)
protected val appRouter: AppRouter = mockk(relaxed = true)
protected val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true)
protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase =
mockk(relaxed = true)
protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true)
protected val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true)
protected val parseQrCodeUseCase: ParseQrCodeUseCase = mockk(relaxed = true)
@ -260,7 +256,6 @@ internal abstract class SendModelTestBase {
destinationUM = DestinationUM.Empty(),
feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
confirmData = null,
),
cryptoCurrencyStatus: CryptoCurrencyStatus = testCryptoCurrencyStatus,
@ -278,7 +273,6 @@ internal abstract class SendModelTestBase {
isAccountModeFlow = kotlinx.coroutines.flow.MutableStateFlow(false),
appCurrency = AppCurrency.Default,
callback = mockk(relaxed = true),
currentRoute = kotlinx.coroutines.flow.flowOf(),
isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false),
predefinedValues = PredefinedValues.Empty,
onLoadFee = { Either.Right(mockk(relaxed = true)) },

View file

@ -7,36 +7,25 @@ import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
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.decompose.model.MutableParamsContainer
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce
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.common.ui.state.ConfirmUM
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.send.SendModelTestBase
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.test.core.ProvideTestModels
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import io.mockk.*
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@ -73,10 +62,30 @@ internal class SendConfirmModelTest : SendModelTestBase() {
// Assert
if (model.expectedSendInitiated) {
coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any()) }
coVerify(exactly = 1) {
createTransferTransactionUseCase(
any(),
any<Fee>(),
any(),
any(),
any(),
any(),
any()
)
}
coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
} else {
coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any()) }
coVerify(exactly = 0) {
createTransferTransactionUseCase(
any(),
any<Fee>(),
any(),
any(),
any(),
any(),
any()
)
}
coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
}
}
@ -108,9 +117,29 @@ internal class SendConfirmModelTest : SendModelTestBase() {
// Assert
if (model.expectedSendInitiated) {
coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any()) }
coVerify(exactly = 1) {
createTransferTransactionUseCase(
any(),
any<Fee>(),
any(),
any(),
any(),
any(),
any()
)
}
} else {
coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any()) }
coVerify(exactly = 0) {
createTransferTransactionUseCase(
any(),
any<Fee>(),
any(),
any(),
any(),
any(),
any()
)
}
}
}
@ -161,7 +190,8 @@ internal class SendConfirmModelTest : SendModelTestBase() {
fun `GIVEN successful send WHEN verifyAndSend THEN notify onSendTransaction`() = runTest {
// Arrange
val onSendTransaction = mockk<() -> Unit>(relaxed = true)
val callback = mockk<com.tangem.features.send.send.confirm.SendConfirmComponent.ModelCallback>(relaxed = true)
val callback =
mockk<com.tangem.features.send.send.confirm.SendConfirmComponent.ModelCallback>(relaxed = true)
val resultFlow = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow
coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right()
@ -256,7 +286,6 @@ internal class SendConfirmModelTest : SendModelTestBase() {
destinationUM = destination,
feeSelectorUM = feeSelector,
confirmUM = mockk<ConfirmUM.Content>(relaxed = true),
navigationUM = NavigationUM.Empty,
confirmData = null,
)
}

View file

@ -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<CloseButtonClickedEvent>()) }
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<Exception, …> would break getOrElse, so stub a Right.
every { listenToQrScanningUseCase(any()) } returns emptyFlow<String>().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,
)
}
}

View file

@ -11,7 +11,6 @@ import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.send.SendModelTestBase
import io.mockk.coEvery
import io.mockk.mockk
@ -33,7 +32,6 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN amount route AND predefined main screen QR WHEN onNextClick THEN push Confirm`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
model.predefinedValues = PredefinedValues.Content.QrCode(
amount = "1.0",
address = "addr123",
@ -42,7 +40,7 @@ internal class SendModelTest : SendModelTestBase() {
)
// Act
model.onNextClick()
model.onNextClick(CommonSendRoute.Amount(isEditMode = false))
// Assert
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
@ -52,11 +50,10 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN amount route AND NOT main screen QR WHEN onNextClick THEN push Destination`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
model.predefinedValues = PredefinedValues.Empty
// Act
model.onNextClick()
model.onNextClick(CommonSendRoute.Amount(isEditMode = false))
// Assert
verify(exactly = 1) { router.push(CommonSendRoute.Destination(isEditMode = false), any()) }
@ -66,10 +63,9 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN destination route WHEN onNextClick THEN push Confirm`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Destination(isEditMode = false)
// Act
model.onNextClick()
model.onNextClick(CommonSendRoute.Destination(isEditMode = false))
// Assert
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
@ -79,10 +75,9 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN route in edit mode WHEN onNextClick THEN pop without push`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = true)
// Act
model.onNextClick()
model.onNextClick(CommonSendRoute.Amount(isEditMode = true))
// Assert
verify(exactly = 1) { router.pop(any()) }
@ -90,19 +85,18 @@ internal class SendModelTest : SendModelTestBase() {
}
@Test
fun `GIVEN confirm route WHEN onNextClick THEN pop (Confirm isEditMode is true so push branch is dead)`() =
fun `GIVEN confirm route WHEN onNextClick THEN push ConfirmSuccess`() =
runTest {
// Arrange
// CommonSendRoute.Confirm.isEditMode == true, so onNextClick short-circuits to onBackClick().
// CommonSendRoute.Confirm.isEditMode == false, so onNextClick pushes ConfirmSuccess.
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Confirm
// Act
model.onNextClick()
model.onNextClick(CommonSendRoute.Confirm)
// Assert
verify(exactly = 1) { router.pop(any()) }
verify(exactly = 0) { router.push(CommonSendRoute.ConfirmSuccess, any()) }
verify(exactly = 1) { router.push(CommonSendRoute.ConfirmSuccess, any()) }
verify(exactly = 0) { router.pop(any()) }
}
}
@ -210,10 +204,9 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN amount route non-edit WHEN onBackClick THEN send analytics and pop`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
// Act
model.onBackClick()
model.onBackClick(CommonSendRoute.Amount(isEditMode = false))
// Assert
verify(exactly = 1) { analyticsEventHandler.send(any<CommonSendAnalyticEvents.CloseButtonClicked>()) }
@ -224,10 +217,9 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN destination route edit WHEN onBackClick THEN pop without analytics`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Destination(isEditMode = true)
// Act
model.onBackClick()
model.onBackClick(CommonSendRoute.Destination(isEditMode = true))
// Assert
verify(exactly = 0) { analyticsEventHandler.send(any<CommonSendAnalyticEvents.CloseButtonClicked>()) }
@ -251,7 +243,6 @@ internal class SendModelTest : SendModelTestBase() {
assertThat(state.feeSelectorUM).isEqualTo(FeeSelectorUMRedesigned.Loading)
assertThat(state.confirmUM).isEqualTo(ConfirmUM.Empty)
assertThat(state.confirmData).isNull()
assertThat(state.navigationUM).isEqualTo(NavigationUM.Empty)
verify(exactly = 1) { router.popTo(CommonSendRoute.Amount(isEditMode = false), any()) }
}
}

View file

@ -6,9 +6,7 @@ import arrow.core.right
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
@ -32,50 +30,36 @@ 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.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce
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.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.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger
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.loadedStatus
import com.tangem.features.send.testDispatcherProvider
import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper
import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent
import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import com.tangem.features.send.testDispatcherProvider
import com.tangem.test.core.ProvideTestModels
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import io.mockk.verify
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
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.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
@OptIn(ExperimentalCoroutinesApi::class)
internal class NFTSendConfirmModelTest {
@ -166,10 +150,30 @@ internal class NFTSendConfirmModelTest {
// Assert
if (model.expectedSendInitiated) {
coVerify(exactly = 1) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) }
coVerify(exactly = 1) {
createNFTTransferTransactionUseCase(
any(),
any(),
any(),
any(),
any(),
any(),
any()
)
}
coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
} else {
coVerify(exactly = 0) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) }
coVerify(exactly = 0) {
createNFTTransferTransactionUseCase(
any(),
any(),
any(),
any(),
any(),
any(),
any()
)
}
coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
}
}
@ -303,7 +307,6 @@ internal class NFTSendConfirmModelTest {
account = null,
isAccountsMode = false,
callback = mockk(relaxed = true),
currentRoute = flowOf(),
isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false),
onLoadFee = { mockk<com.tangem.blockchain.common.transaction.TransactionFee>(relaxed = true).right() },
onSendTransaction = {},
@ -328,7 +331,6 @@ internal class NFTSendConfirmModelTest {
destinationUM = destination,
feeSelectorUM = feeSelector,
confirmUM = mockk<ConfirmUM.Content>(relaxed = true),
navigationUM = NavigationUM.Empty,
)
}

View file

@ -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<NFTAsset>(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,
)
}
}

View file

@ -102,31 +102,41 @@ internal class NFTSendModelTest {
// Arrange
val sut = buildModel()
advanceUntilIdle()
sut.currentRouteFlow.value = model.route
// Act
sut.onNextClick()
sut.onNextClick(model.route)
advanceUntilIdle()
// Assert
if (model.expectPushConfirm) {
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
verify(exactly = 0) { router.pop(any()) }
} else {
verify(exactly = 1) { router.pop(any()) }
verify(exactly = 0) { router.push(any(), any()) }
// Confirm.isEditMode == true, so the `Confirm -> replaceAll(ConfirmSuccess)` branch is unreachable
verify(exactly = 0) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = any()) }
when (model.expectedAction) {
NextClickAction.PushConfirm -> {
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
verify(exactly = 0) { router.pop(any()) }
verify(exactly = 0) { router.replaceAll(*anyVararg(), onComplete = any()) }
}
NextClickAction.ReplaceAllConfirmSuccess -> {
// Confirm route → replaceAll(ConfirmSuccess)
verify(exactly = 1) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = any()) }
verify(exactly = 0) { router.pop(any()) }
verify(exactly = 0) { router.push(any(), any()) }
}
NextClickAction.Pop -> {
verify(exactly = 1) { router.pop(any()) }
verify(exactly = 0) { router.push(any(), any()) }
verify(exactly = 0) { router.replaceAll(*anyVararg(), onComplete = any()) }
}
}
}
private fun provideTestModels() = listOf(
NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectPushConfirm = true),
NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectPushConfirm = false),
NextClickModel(route = CommonSendRoute.Confirm, expectPushConfirm = false),
NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectedAction = NextClickAction.PushConfirm),
NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectedAction = NextClickAction.Pop),
NextClickModel(route = CommonSendRoute.Confirm, expectedAction = NextClickAction.ReplaceAllConfirmSuccess),
)
}
enum class NextClickAction { PushConfirm, ReplaceAllConfirmSuccess, Pop }
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnBackClick {
@ -138,10 +148,9 @@ internal class NFTSendModelTest {
// Arrange
val sut = buildModel()
advanceUntilIdle()
sut.currentRouteFlow.value = model.route
// Act
sut.onBackClick()
sut.onBackClick(model.route)
advanceUntilIdle()
// Assert
@ -221,7 +230,7 @@ internal class NFTSendModelTest {
)
}
data class NextClickModel(val route: CommonSendRoute, val expectPushConfirm: Boolean)
data class NextClickModel(val route: CommonSendRoute, val expectedAction: NextClickAction)
data class BackClickModel(val route: CommonSendRoute, val expectedTriggerCalls: Int)

View file

@ -1,8 +1,11 @@
package com.tangem.features.send.subcomponents.amount.model
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
@ -13,43 +16,28 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.loadedStatus
import com.tangem.features.send.testDispatcherProvider
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.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.loadedStatus
import com.tangem.features.send.testDispatcherProvider
import com.tangem.test.core.ProvideTestModels
import com.google.common.truth.Truth.assertThat
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@ -79,7 +67,14 @@ internal class SendAmountModelTest {
fun setUp() {
MockKAnnotations.init(this)
// PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows.
clearMocks(callback, sendAmountAlertFactory, analyticsEventHandler, answers = false, recordedCalls = true, childMocks = false)
clearMocks(
callback,
sendAmountAlertFactory,
analyticsEventHandler,
answers = false,
recordedCalls = true,
childMocks = false
)
every { getUserWalletUseCase.invokeFlow(testUserWalletId) } returns flowOf(coldWallet().right())
coEvery { getMinimumTransactionAmountSyncUseCase(any(), any()) } returns BigDecimal.ONE.right()
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
@ -107,12 +102,7 @@ internal class SendAmountModelTest {
PredefinedValues.Empty
}
// Start off an Amount route so the navigation combine stays idle until the wallet is loaded.
val currentRoute = MutableStateFlow<CommonSendRoute>(CommonSendRoute.Confirm)
val sut = buildModel(predefinedValues = predefined, currentRoute = currentRoute)
advanceUntilIdle()
// Act — flip to Amount so setSendWithSwapAvailability() re-runs with the loaded wallet
currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
val sut = buildModel(predefinedValues = predefined, route = CommonSendRoute.Amount(false))
advanceUntilIdle()
// Assert
@ -138,7 +128,7 @@ internal class SendAmountModelTest {
fun `WHEN onConvertToAnotherToken THEN reset-alert in edit mode else convert directly`(model: ConvertModel) =
runTest {
// Arrange
val sut = buildModel(currentRoute = MutableStateFlow(CommonSendRoute.Amount(isEditMode = model.isEditMode)))
val sut = buildModel(route = CommonSendRoute.Amount(isEditMode = model.isEditMode))
advanceUntilIdle()
// Act
@ -248,7 +238,10 @@ internal class SendAmountModelTest {
}
private fun provideTestModels() = listOf(
AmountNextModel(isFiat = true, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.AppCurrency),
AmountNextModel(
isFiat = true,
expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.AppCurrency
),
AmountNextModel(isFiat = false, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.Token),
)
}
@ -257,7 +250,7 @@ internal class SendAmountModelTest {
private fun TestScope.buildModel(
predefinedValues: PredefinedValues = PredefinedValues.Empty,
currentRoute: MutableStateFlow<CommonSendRoute> = MutableStateFlow(CommonSendRoute.Amount(isEditMode = false)),
route: CommonSendRoute.Amount = CommonSendRoute.Amount(isEditMode = false),
cryptoCurrencyStatusFlow: MutableStateFlow<CryptoCurrencyStatus> =
MutableStateFlow(loadedStatus(cryptoCurrency, balance = BigDecimal.TEN)),
state: AmountState = AmountState.Empty,
@ -275,7 +268,7 @@ internal class SendAmountModelTest {
accountFlow = MutableStateFlow<Account?>(null),
isAccountModeFlow = MutableStateFlow(false),
callback = callback,
currentRoute = currentRoute.filterIsInstance<AmountRoute>(),
route = route,
)
return SendAmountModel(
paramsContainer = MutableParamsContainer(params),

View file

@ -1,14 +1,11 @@
package com.tangem.features.send.subcomponents.amount.model
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
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.model.MutableParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
@ -25,13 +22,11 @@ import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentPara
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@ -46,12 +41,14 @@ import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Guards the route-decoupling fix: `SendAmountModel.configAmountNavigation()` filters its route flow on
* the public `AmountRoute` interface, not the `internal CommonSendRoute.Amount`. The test feeds a
* foreign `AmountRoute` (which is NOT a `CommonSendRoute.Amount`) and asserts the navigation result is
* still produced before the fix the `combine`'s `filterIsInstance<CommonSendRoute.Amount>()` dropped
* it and `onNavigationResult` never fired, leaving an external host (e.g. staking) with a dead Next
* button. Also checks the `isEditMode` back-icon / primary-button mapping is unaffected.
* Guards the route-decoupling fix at the model level. The amount step is now hostable by foreign flows
* (e.g. staking / send-with-swap) that supply their own [AmountRoute] implementation rather than the
* `internal CommonSendRoute.Amount`. The navigation chrome (back icon / Next vs Continue) moved out of
* the model into `DefaultSendAmountComponent`, but the model still makes a route-driven decision in
* [SendAmountModel.onConvertToAnotherToken]: it reads `params.route.isEditMode` *directly* (previously
* it collected the route flow). This test feeds a foreign [AmountRoute] and verifies that edit-mode
* branches to the "reset sending" alert while a fresh (non-edit) route converts immediately both via
* the public interface, not `CommonSendRoute`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class SendAmountNavigationTest {
@ -79,10 +76,11 @@ internal class SendAmountNavigationTest {
sendAmountUpdateListener,
getSelectedAppCurrencyUseCase,
getUserWalletUseCase,
sendAmountAlertFactory,
callback,
)
// No wallet → the model stays on AmountState.Empty (the heavy AmountStateConverter path is skipped),
// which is all the navigation block needs to emit.
// which is all these route-driven assertions need.
every { getUserWalletUseCase.invokeFlow(any()) } returns emptyFlow()
every { sendAmountReduceListener.reduceToTriggerFlow } returns emptyFlow()
every { sendAmountReduceListener.reduceByTriggerFlow } returns emptyFlow()
@ -94,43 +92,41 @@ internal class SendAmountNavigationTest {
@AfterEach
fun tearDown() {
// Cancel modelScope so the long-lived navigation/status collectors stop between tests.
// Cancel modelScope so the long-lived reduce/status collectors stop between tests.
model?.onDestroy()
model = null
}
@Test
fun `GIVEN a foreign AmountRoute WHEN model created THEN navigation produced with close icon and next button`() =
runTest {
// Arrange — a route that is NOT CommonSendRoute.Amount (the impl type the model used to filter on).
val navSlot = slot<NavigationUM>()
// Act
createModel(testScope = this, route = TestAmountRoute(isEditMode = false))
advanceUntilIdle()
// Assert — before the fix this never fired for a non-CommonSendRoute.Amount route.
verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) }
val content = navSlot.captured as NavigationUM.Content
assertThat(content.backIconRes).isEqualTo(R.drawable.ic_close_24)
assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_next))
}
@Test
fun `GIVEN a foreign AmountRoute in edit mode WHEN model created THEN navigation has back icon and continue button`() =
fun `GIVEN foreign AmountRoute in edit mode WHEN onConvertToAnotherToken THEN reset sending alert shown`() =
runTest {
// Arrange
val navSlot = slot<NavigationUM>()
// Act
createModel(testScope = this, route = TestAmountRoute(isEditMode = true))
advanceUntilIdle()
// Assert
verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) }
val content = navSlot.captured as NavigationUM.Content
assertThat(content.backIconRes).isEqualTo(R.drawable.ic_back_24)
assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_continue))
// Act
model?.onConvertToAnotherToken()
advanceUntilIdle()
// Assert — edit mode must guard the conversion behind the reset-sending confirmation.
verify(exactly = 1) { sendAmountAlertFactory.showResetSendingAlert(any()) }
verify(exactly = 0) { callback.onConvertToAnotherToken(any(), any()) }
}
@Test
fun `GIVEN foreign AmountRoute not in edit mode WHEN onConvertToAnotherToken THEN converts without alert`() =
runTest {
// Arrange
createModel(testScope = this, route = TestAmountRoute(isEditMode = false))
advanceUntilIdle()
// Act
model?.onConvertToAnotherToken()
advanceUntilIdle()
// Assert — a fresh route converts immediately, with no reset-sending alert.
verify(exactly = 0) { sendAmountAlertFactory.showResetSendingAlert(any()) }
verify(exactly = 1) { callback.onConvertToAnotherToken(any(), any()) }
}
private fun createModel(testScope: TestScope, route: AmountRoute): SendAmountModel {
@ -147,7 +143,7 @@ internal class SendAmountNavigationTest {
accountFlow = MutableStateFlow<Account?>(null),
isAccountModeFlow = MutableStateFlow(false),
callback = callback,
currentRoute = MutableStateFlow(route),
route = route,
)
return SendAmountModel(
paramsContainer = MutableParamsContainer(value = params),

View file

@ -2,6 +2,8 @@ package com.tangem.features.send.subcomponents.destination.model
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
@ -9,6 +11,8 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.CryptoCurrencyAddress
@ -21,25 +25,16 @@ import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.send.api.entity.PredefinedValues
import kotlinx.collections.immutable.toImmutableList
import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
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
@ -49,13 +44,14 @@ import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFa
import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents
import com.tangem.features.send.testDispatcherProvider
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import com.tangem.test.core.ProvideTestModels
import io.mockk.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -64,7 +60,6 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
@ -127,7 +122,15 @@ internal class SendDestinationModelTest {
fun `GIVEN valid non-problematic address WHEN address entered THEN send valid analytics without backup alert`() =
runTest {
// Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns
coEvery {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
} returns
AddressValidation.Success.Valid.right()
val sut = buildModel()
advanceUntilIdle()
@ -144,14 +147,22 @@ internal class SendDestinationModelTest {
}
verify(exactly = 0) { sendDestinationAlertFactory.showRecipientBackupErrorAlert(any()) }
// InputField is not an auto-next source → no auto-advance even for a valid address
verify(exactly = 0) { callback.onNextClick() }
verify(exactly = 0) { callback.onNextClick(CommonSendRoute.Destination(false)) }
}
@Test
fun `GIVEN valid backup-problematic address WHEN address entered THEN show recipient backup error alert`() =
runTest {
// Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns
coEvery {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
} returns
AddressValidation.Success.Valid.right()
coEvery { getBackupProblematicWalletForAddressUseCase(any()) } returns testUserWalletId
val sut = buildModel()
@ -172,7 +183,15 @@ internal class SendDestinationModelTest {
@Test
fun `GIVEN invalid address WHEN address entered THEN send invalid analytics`() = runTest {
// Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns
coEvery {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
} returns
AddressValidation.Error.InvalidAddress.left()
val sut = buildModel()
advanceUntilIdle()
@ -193,7 +212,15 @@ internal class SendDestinationModelTest {
fun `GIVEN memo change with null type WHEN handled THEN no address-entered analytics and no auto-next`() =
runTest {
// Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns
coEvery {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
} returns
AddressValidation.Success.Valid.right()
val sut = buildModel()
advanceUntilIdle()
@ -206,7 +233,7 @@ internal class SendDestinationModelTest {
verify(exactly = 0) {
analyticsEventHandler.send(any<SendDestinationAnalyticEvents.AddressEntered>())
}
verify(exactly = 0) { callback.onNextClick() }
verify(exactly = 0) { callback.onNextClick(CommonSendRoute.Destination(false)) }
}
}
@ -231,7 +258,7 @@ internal class SendDestinationModelTest {
advanceUntilIdle()
// Assert
verify(exactly = model.expectedNextClicks) { callback.onNextClick() }
verify(exactly = model.expectedNextClicks) { callback.onNextClick(CommonSendRoute.Destination(false)) }
}
private fun provideTestModels() = listOf(
@ -256,7 +283,15 @@ internal class SendDestinationModelTest {
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) }
coVerify(exactly = 0) {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
}
}
}
@ -286,25 +321,38 @@ internal class SendDestinationModelTest {
}
@Test
fun `GIVEN a contact is set WHEN route switches to edit mode THEN the contact is reset`() = runTest {
// Arrange
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
val currentRoute = MutableStateFlow<DestinationRoute>(CommonSendRoute.Destination(isEditMode = false))
val sut = buildModel(currentRoute = currentRoute)
advanceUntilIdle()
sut.applySelectedContact(selectedContact(name = "Dave", address = "0xDave"))
advanceUntilIdle()
assertThat(content(sut).addressTextField.contactName).isEqualTo("Dave")
fun `GIVEN a contact is pre-set in state AND route is edit mode WHEN model initializes THEN the contact is reset`() =
runTest {
// Arrange — build initial state with a contact name already set and isInitialized = true
// so the InitialStateTransformer does NOT overwrite it, leaving resetContactOnEdit() to clear it.
val stateWithContact = DestinationUM.Content(
isPrimaryButtonEnabled = false,
isInitialized = true,
addressTextField = DestinationTextFieldUM.RecipientAddress(
value = "0xDave",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Text),
placeholder = com.tangem.core.ui.extensions.stringReference(""),
label = com.tangem.core.ui.extensions.stringReference(""),
isValuePasted = false,
contactName = "Dave",
),
memoTextField = null,
recent = persistentListOf(),
wallets = persistentListOf(),
networkName = "Ethereum",
isRecentHidden = false,
)
// Act — entering edit mode must clear the bound contact
currentRoute.value = CommonSendRoute.Destination(isEditMode = true)
advanceUntilIdle()
// Act — init with isEditMode = true triggers resetContactOnEdit()
val sut = buildModel(
currentRoute = CommonSendRoute.Destination(isEditMode = true),
initialState = stateWithContact,
)
advanceUntilIdle()
// Assert
assertThat(content(sut).addressTextField.contactName).isNull()
}
// Assert
assertThat(content(sut).addressTextField.contactName).isNull()
}
}
@Nested
@ -335,9 +383,19 @@ internal class SendDestinationModelTest {
private fun provideTestModels() = listOf(
// saved "0xAddr", entered "0xaddr" → case-insensitive match
ContactRecognitionModel(savedName = "Alice", savedAddress = "0xAddr", enteredAddress = "0xaddr", expectedContactName = "Alice"),
ContactRecognitionModel(
savedName = "Alice",
savedAddress = "0xAddr",
enteredAddress = "0xaddr",
expectedContactName = "Alice"
),
// entered address not among saved contacts → no recognition
ContactRecognitionModel(savedName = "Alice", savedAddress = "0xOther", enteredAddress = "0xAddr", expectedContactName = null),
ContactRecognitionModel(
savedName = "Alice",
savedAddress = "0xOther",
enteredAddress = "0xAddr",
expectedContactName = null
),
)
}
@ -411,29 +469,44 @@ internal class SendDestinationModelTest {
private fun provideTestModels() = listOf(
// not available -> never shown, even for a fresh valid address
AddContactModel(isAddContactAvailable = false, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = false),
AddContactModel(
isAddContactAvailable = false,
savedAddresses = emptyList(),
enteredAddress = "0xFresh",
expectedShown = false
),
// available + address not in the book -> shown
AddContactModel(isAddContactAvailable = true, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = true),
AddContactModel(
isAddContactAvailable = true,
savedAddresses = emptyList(),
enteredAddress = "0xFresh",
expectedShown = true
),
// available but address already saved -> hidden
AddContactModel(isAddContactAvailable = true, savedAddresses = listOf("0xSaved"), enteredAddress = "0xSaved", expectedShown = false),
AddContactModel(
isAddContactAvailable = true,
savedAddresses = listOf("0xSaved"),
enteredAddress = "0xSaved",
expectedShown = false
),
)
}
// region fixtures
private fun TestScope.buildModel(
currentRoute: MutableStateFlow<DestinationRoute> =
MutableStateFlow(CommonSendRoute.Destination(isEditMode = false)),
currentRoute: DestinationRoute = CommonSendRoute.Destination(isEditMode = false),
initialState: DestinationUM = DestinationUM.Empty(),
): SendDestinationModel {
val params = SendDestinationComponentParams.DestinationParams(
state = DestinationUM.Empty(),
state = initialState,
analyticsCategoryName = "test_send",
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
cryptoCurrency = cryptoCurrency,
userWalletId = testUserWalletId,
title = stringReference("Send to"),
isBalanceHidingFlow = MutableStateFlow(false),
currentRoute = currentRoute,
route = currentRoute,
callback = callback,
isAllowSelfSend = false,
)