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,7 +1,8 @@
package com.tangem.common.ui.navigationButtons
import com.tangem.core.decompose.navigation.Route
interface NavigationModelCallback {
fun onNavigationResult(navigationUM: NavigationUM)
fun onBackClick()
fun onNextClick()
fun onBackClick(currentRoute: Route)
fun onNextClick(currentRoute: Route)
}

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,7 +83,6 @@ 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
@ -110,11 +99,11 @@ internal class DefaultSendComponent @AssistedInject constructor(
type = model.consumeEntryType(),
),
)
if (model.currentRoute.value.isEditMode) {
if (childStack.value.active.configuration.isEditMode) {
activeComponent.updateState(model.uiState.value)
}
}
is DefaultSendAmountComponent -> {
is SendAmountComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AmountScreenOpened(
categoryName = model.analyticCategoryName,
@ -134,42 +123,37 @@ internal class DefaultSendComponent @AssistedInject constructor(
activeComponent.updateState(model.uiState.value.destinationUM)
}
}
model.currentRoute.emit(stack.active.configuration)
}
}
}
@Composable
override fun Content(modifier: Modifier) {
val stackState by childStack.subscribeAsState()
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler(
onBack = {
(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,32 +27,7 @@ internal class SendConfirmSuccessModel @Inject constructor(
private val _uiState = params.sendUMFlow
val uiState = _uiState
init {
configConfirmSuccessNavigation()
}
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 = {
fun onBackClick() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
@ -73,39 +36,14 @@ internal class SendConfirmSuccessModel @Inject constructor(
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,7 +75,6 @@ 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
@ -93,7 +90,8 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
toDerivationIndex = null,
),
)
if (model.currentRouteFlow.value.isEditMode) {
// Push current state into a reused Confirm on (re)entry. Confirm.isEditMode is `true`
if (stack.active.configuration.isEditMode) {
activeComponent.updateState(model.uiState.value)
}
}
@ -107,37 +105,38 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
activeComponent.updateState(model.uiState.value.destinationUM)
}
}
model.currentRouteFlow.emit(stack.active.configuration)
}
}
}
@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(
private fun getDestinationComponent(
route: DestinationRoute,
factoryContext: AppComponentContext,
): ComposableModularContentComponent = destinationComponentFactory.create(
context = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = model.currentRouteFlow.filterIsInstance<CommonSendRoute.Destination>(),
route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow,
title = resourceReference(R.string.nft_send),
analyticsCategoryName = analyticsCategoryName,
@ -148,7 +147,8 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
),
)
private fun getConfirmComponent(factoryContext: AppComponentContext) = nftSendConfirmComponentFactory.create(
private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
return nftSendConfirmComponentFactory.create(
appComponentContext = factoryContext,
params = NFTSendConfirmComponent.Params(
state = model.uiState.value,
@ -160,7 +160,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus,
appCurrency = model.appCurrency,
callback = model,
currentRoute = model.currentRouteFlow.filterIsInstance<CommonSendRoute.Confirm>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
onLoadFee = model::loadFee,
analyticsSendSource = analyticsSendSource,
@ -169,13 +168,14 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) },
),
)
}
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent {
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 = {
fun onBackClick() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = confirmUM.isPrimaryButtonEnabled,
isValid = uiState.value.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)
}
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,32 +27,7 @@ internal class NFTSendSuccessModel @Inject constructor(
val uiState = params.nftSendUMFlow
init {
configConfirmSuccessNavigation()
}
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 = {
fun onBackClick() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
@ -71,37 +36,14 @@ internal class NFTSendSuccessModel @Inject constructor(
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,10 +51,9 @@ internal fun NFTSendSuccessContent(
label = "Animate success content",
modifier = modifier,
) {
Column {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(TangemTheme.colors.background.tertiary),
) {
SuccessContent(
@ -69,15 +67,6 @@ internal fun NFTSendSuccessContent(
backgroundColor = TangemTheme.colors.background.tertiary,
)
}
NavigationButtonsBlockV2(
navigationUM = nftSendUM.navigationUM,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
}

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 (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) {
when (model.expectedAction) {
NextClickAction.PushConfirm -> {
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
verify(exactly = 0) { router.pop(any()) }
} else {
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()) }
// Confirm.isEditMode == true, so the `Confirm -> replaceAll(ConfirmSuccess)` branch is unreachable
verify(exactly = 0) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = 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,20 +321,33 @@ 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)
// Act — init with isEditMode = true triggers resetContactOnEdit()
val sut = buildModel(
currentRoute = CommonSendRoute.Destination(isEditMode = true),
initialState = stateWithContact,
)
advanceUntilIdle()
// Assert
@ -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,
)

View file

@ -98,4 +98,6 @@ dependencies {
testImplementation(deps.test.junit5)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
testImplementation(deps.test.coroutine)
testImplementation(projects.common.test)
}

View file

@ -1,9 +1,13 @@
package com.tangem.features.swap.v2.impl.amount
import androidx.compose.foundation.background
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.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
@ -14,10 +18,14 @@ import com.tangem.common.ui.navigationButtons.NavigationModelCallback
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.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.SwapAmountModel
import com.tangem.features.swap.v2.impl.amount.ui.SwapAmountContent
@ -28,7 +36,7 @@ import dagger.assisted.AssistedInject
internal class SwapAmountComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SwapAmountComponentParams.AmountParams,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SwapAmountModel = getOrCreateModel(params = params)
@ -49,6 +57,23 @@ internal class SwapAmountComponent @AssistedInject constructor(
fun updateState(amountUM: SwapAmountUM) = 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 amountUM by model.uiState.collectAsStateWithLifecycle()
@ -63,6 +88,30 @@ internal class SwapAmountComponent @AssistedInject constructor(
rateInfo.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.onAmountNext()
params.callback.onNextClick(params.route)
},
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
private fun rateInfoChild(
config: ExpressRateType,
componentContext: ComponentContext,

View file

@ -10,7 +10,6 @@ import com.tangem.domain.swap.models.SwapDirection
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal sealed class SwapAmountComponentParams {
@ -41,7 +40,7 @@ internal sealed class SwapAmountComponentParams {
override val isAccountModeFlow: StateFlow<Boolean>,
val title: TextReference,
val callback: SwapAmountComponent.ModelCallback,
val currentRoute: Flow<SendWithSwapRoute>,
val route: SendWithSwapRoute,
) : SwapAmountComponentParams()
data class AmountBlockParams(

View file

@ -8,14 +8,11 @@ import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.common.ui.notifications.NotificationId
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.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.datasource.local.swap.SwapBestRateAnimationStore
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -40,7 +37,6 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkListener
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent.SwapChooseProviderConfig
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams
import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceListener
@ -54,7 +50,6 @@ import com.tangem.features.swap.v2.impl.amount.model.transformers.*
import com.tangem.features.swap.v2.impl.chooseprovider.SwapChooseProviderComponent
import com.tangem.features.swap.v2.impl.common.SwapAlertFactory
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents.NoticeFixedRate.toAnalyticsRateType
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -125,7 +120,6 @@ internal class SwapAmountModel @Inject constructor(
private val amountAnalyticsSender = SwapAmountAnalyticsSender(analyticsEventHandler)
private var autoUpdateSubscriberJob: Job? = null
private var navigationJob: Job? = null
val uiState: StateFlow<SwapAmountUM>
field = MutableStateFlow(params.amountUM)
@ -156,7 +150,6 @@ internal class SwapAmountModel @Inject constructor(
} else {
QUOTES_UPDATE_DELAY
}
configAmountNavigation()
quoteTaskScheduler.scheduleTask(
scope = modelScope,
task = loadQuotesTask(initialDelay = initialDelay),
@ -167,7 +160,6 @@ internal class SwapAmountModel @Inject constructor(
fun onStop() {
quoteTaskScheduler.cancelTask()
autoUpdateSubscriberJob?.cancel()
navigationJob?.cancel()
}
override fun onDestroy() {
@ -325,7 +317,7 @@ internal class SwapAmountModel @Inject constructor(
val isShowSendViaSwapNotification = shouldShowNotificationUseCase(
NotificationId.SendViaSwapTokenSelectorNotification.key,
)
val isEditMode = amountParams.currentRoute.firstOrNull()?.isEditMode == true
val isEditMode = amountParams.route.isEditMode
val selectedCurrency = (uiState.value as? SwapAmountUM.Content)?.secondaryCryptoCurrencyStatus?.currency
appRouter.push(
AppRoute.ChooseManagedTokens(
@ -354,7 +346,7 @@ internal class SwapAmountModel @Inject constructor(
val amountParams = params as? SwapAmountComponentParams.AmountParams ?: return
modelScope.launch {
if (amountParams.currentRoute.firstOrNull()?.isEditMode == true) {
if (amountParams.route.isEditMode) {
swapAmountAlertFactory.showCloseSendWithSwapAlert {
params.callback.resetSendWithSwapNavigation(resetNavigation = true)
confirmSendWithSwapClose()
@ -934,42 +926,6 @@ internal class SwapAmountModel @Inject constructor(
)
}
private fun configAmountNavigation() {
val params = params as? SwapAmountComponentParams.AmountParams ?: return
navigationJob?.cancel()
navigationJob = combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { (_, route) -> route is SendWithSwapRoute.Amount }.onEach { (state, route) ->
params.callback.onNavigationResult(
NavigationUM.Content(
source = SendWithSwapRoute.Amount::class.java.simpleName,
title = resourceReference(R.string.common_amount),
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 companion object {
const val DEBOUNCE_AMOUNT_DELAY = 500L
const val QUOTES_UPDATE_DELAY = 10000L

View file

@ -4,21 +4,18 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.value.ObserveLifecycleMode
import com.arkivanov.decompose.value.subscribe
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.navigation.url.UrlOpener
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.swap.models.R
import com.tangem.domain.swap.models.SwapDirection
@ -41,7 +38,6 @@ import com.tangem.features.swap.v2.impl.sendviaswap.ui.SendWithSwapContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
internal class DefaultSendWithSwapComponent @AssistedInject constructor(
@ -50,7 +46,6 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
private val sendDestinationComponentFactory: SendDestinationComponent.Factory,
private val confirmComponentFactory: SendWithSwapConfirmComponent.Factory,
private val analyticsEventHandler: AnalyticsEventHandler,
private val urlOpener: UrlOpener,
) : SendWithSwapComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<SendWithSwapRoute>()
@ -99,7 +94,7 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
activeComponent.updateState(model.uiState.value.destinationUM)
}
is SendWithSwapConfirmComponent -> {
if (model.currentRoute.value.isEditMode) {
if (stack.active.configuration.isEditMode) {
activeComponent.updateState(model.uiState.value)
}
// Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate ([REDACTED_TASK_KEY]).
@ -120,7 +115,6 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
)
}
}
model.currentRoute.emit(stack.active.configuration)
}
}
}
@ -128,35 +122,33 @@ internal class DefaultSendWithSwapComponent @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()
},
onBack = ::onChildBack,
)
SendWithSwapContent(
navigationUM = state.navigationUM,
confirmUM = state.confirmUM,
stackState = stackState,
onLinkClick = urlOpener::openUrl,
)
}
private fun createChild(route: SendWithSwapRoute, childContext: AppComponentContext) = when (route) {
is SendWithSwapRoute.Amount -> getAmountComponent(factoryContext = childContext)
is SendWithSwapRoute.Destination -> getDestinationComponent(factoryContext = childContext)
is SendWithSwapRoute.Amount -> getAmountComponent(route, factoryContext = childContext)
is SendWithSwapRoute.Destination -> getDestinationComponent(route = route, factoryContext = childContext)
is SendWithSwapRoute.Confirm -> getConfirmComponent(factoryContext = childContext)
is SendWithSwapRoute.Success -> getSuccessComponent(factoryContext = childContext)
}
private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent {
private fun getAmountComponent(
route: SendWithSwapRoute.Amount,
factoryContext: AppComponentContext,
): ComposableModularContentComponent {
return SwapAmountComponent(
appComponentContext = factoryContext,
params = SwapAmountComponentParams.AmountParams(
amountUM = model.uiState.value.amountUM,
title = resourceReference(R.string.common_send),
currentRoute = model.currentRoute,
route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName,
primaryCryptoCurrencyStatusFlow = model.primaryCryptoCurrencyStatusFlow,
@ -172,17 +164,20 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
)
}
private fun getDestinationComponent(factoryContext: AppComponentContext): ComposableContentComponent {
private fun getDestinationComponent(
route: DestinationRoute,
factoryContext: AppComponentContext,
): ComposableModularContentComponent {
val amountContentUM = model.uiState.value.amountUM as? SwapAmountUM.Content
?: return ComposableContentComponent.EMPTY
?: return ComposableModularContentComponent.EMPTY
val secondaryCryptoCurrency = amountContentUM.secondaryCryptoCurrencyStatus?.currency
?: return ComposableContentComponent.EMPTY
?: return ComposableModularContentComponent.EMPTY
return sendDestinationComponentFactory.create(
context = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = model.currentRoute.filterIsInstance<DestinationRoute>(),
route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName,
analyticsSendSource = model.analyticsSendSource,
@ -195,12 +190,11 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
)
}
private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableContentComponent {
private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
return confirmComponentFactory.create(
appComponentContext = factoryContext,
params = SendWithSwapConfirmComponent.Params(
sendWithSwapUM = model.uiState.value,
currentRoute = model.currentRoute.filterIsInstance<SendWithSwapRoute.Confirm>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
appCurrency = model.appCurrency,
userWallet = model.userWallet,
@ -216,12 +210,11 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
)
}
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent {
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
return SendWithSwapSuccessComponent(
appComponentContext = factoryContext,
params = SendWithSwapSuccessComponent.Params(
sendWithSwapUMFlow = model.uiState,
currentRoute = model.currentRoute.filterIsInstance<SendWithSwapRoute.Success>(),
callback = model,
analyticsCategoryName = model.analyticCategoryName,
),

View file

@ -1,37 +1,52 @@
package com.tangem.features.swap.v2.impl.sendviaswap.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.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.*
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
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapDirection
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.feeSelector.params.FeeSelectorParams.*
import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent
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.api.subcomponents.feeSelector.params.FeeSelectorParams.*
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams
import com.tangem.features.swap.v2.impl.common.SwapUtils.SEND_WITH_SWAP_PROVIDER_TYPES
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.SendWithSwapConfirmModel
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.ui.SendWithSwapConfirmContent
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -44,7 +59,8 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory,
feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory,
sendNotificationsComponentFactory: SendNotificationsComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val urlOpener: UrlOpener,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SendWithSwapConfirmModel = getOrCreateModel(params = params)
@ -177,6 +193,17 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
}
}
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
text = stringResourceSafe(R.string.send_with_swap_confirm_title),
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 sendWithSwapUM by model.uiState.collectAsStateWithLifecycle()
@ -196,13 +223,120 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
)
}
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
Column {
val confirmUMContent = state.confirmUM as? ConfirmUM.Content
AnimatedVisibility(
visible = confirmUMContent != null,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
val sendFooter = confirmUMContent?.sendingFooter ?: TextReference.EMPTY
val legalFooter = getAnnotatedStringForLegals(
tosUM = confirmUMContent?.tosUM,
sendFooter = sendFooter,
onClick = urlOpener::openUrl,
)
val footerText = remember(sendFooter, legalFooter) {
if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) {
combinedReference(sendFooter, legalFooter)
} else {
TextReference.EMPTY
}
}
SendingText(footerText = footerText)
}
NavigationPrimaryButton(
primaryButton = model.primaryButtonUM(state.confirmUM),
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
@Composable
private fun getAnnotatedStringForLegals(
tosUM: ConfirmUM.Content.TosUM?,
sendFooter: TextReference,
onClick: (String) -> Unit,
): TextReference {
if (tosUM == null) return TextReference.EMPTY
val tos = tosUM.tosLink
val policy = tosUM.policyLink
return if (tos != null && policy != null) {
val tosTitle = tos.title.resolveReference()
val policyTitle = policy.title.resolveReference()
val fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle)
val tosIndex = fullString.indexOf(tosTitle)
val policyIndex = fullString.indexOf(policyTitle)
annotatedReference {
if (!sendFooter.resolveReference().endsWith(StringsSigns.POINT_SIGN)) {
append(StringsSigns.POINT_SIGN)
}
appendSpace()
append(fullString.substring(0, tosIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "TOS_TAG",
linkInteractionListener = { onClick(tos.link) },
),
block = {
appendColored(
text = fullString.substring(tosIndex, tosIndex + tosTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
append(fullString.substring(tosIndex + tosTitle.length, policyIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "POLICY_TAG",
linkInteractionListener = { onClick(policy.link) },
),
block = {
appendColored(
text = fullString.substring(policyIndex, policyIndex + policyTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
}
} else {
val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" }
val legalTitle = legal.title.resolveReference()
val fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legalTitle)
val legalIndex = fullString.indexOf(legalTitle)
annotatedReference {
append(fullString.substring(0, legalIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "LEGAL_TAG",
linkInteractionListener = { onClick(legal.link) },
),
block = {
appendColored(
text = fullString.substring(legalIndex, legalIndex + legalTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
}
}
}
data class Params(
val sendWithSwapUM: SendWithSwapUM,
val analyticsCategoryName: String,
val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
val userWallet: UserWallet,
val appCurrency: AppCurrency,
val currentRoute: Flow<SendWithSwapRoute>,
val swapDirection: SwapDirection,
val isBalanceHidingFlow: StateFlow<Boolean>,
val primaryCryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>,
@ -218,6 +352,6 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
}
interface ModelCallback {
fun onResult(route: SendWithSwapRoute, sendWithSwapUM: SendWithSwapUM)
fun onResult(sendWithSwapUM: SendWithSwapUM)
}
}

View file

@ -8,7 +8,6 @@ import com.tangem.blockchain.common.transaction.TransactionFee
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
@ -38,14 +37,14 @@ import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
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.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
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.feeSelector.entity.FeeSelectorUM
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.swap.v2.api.SwapFeatureToggles
@ -176,7 +175,6 @@ internal class SendWithSwapConfirmModel @Inject constructor(
init {
updateAmountSubtractAvailability()
configConfirmNavigation()
initialState()
subscribeOnNotificationUpdates()
subscribeOnTapHelpUpdates()
@ -383,6 +381,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
swapDataModel = data,
),
)
params.callback.onResult(uiState.value)
router.replaceAll(SendWithSwapRoute.Success)
},
expressOperationType = ExpressOperationType.SEND_WITH_SWAP,
@ -582,28 +581,12 @@ internal class SendWithSwapConfirmModel @Inject constructor(
}
}
private fun configConfirmNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter {
it.second is SendWithSwapRoute.Confirm
}.onEach { (state, _) ->
val confirmUM = state.confirmUM
fun primaryButtonUM(confirmUM: ConfirmUM): NavigationButton {
val isContent = confirmUM is ConfirmUM.Content
val isReadyToSend = isContent && !confirmUM.isTransactionInProcess
val isHoldToConfirm = params.userWallet.isHotWallet && isContent
params.callback.onResult(
route = SendWithSwapRoute.Confirm,
sendWithSwapUM = state.copy(
navigationUM = NavigationUM.Content(
source = SendWithSwapRoute.Confirm.javaClass.simpleName,
title = resourceReference(id = R.string.send_with_swap_confirm_title),
subtitle = null,
backIconRes = R.drawable.ic_back_24,
backIconClick = router::pop,
primaryButton = NavigationButton(
return NavigationButton(
textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm),
iconRes = walletInterationIcon(params.userWallet),
isIconVisible = isReadyToSend && !isHoldToConfirm,
@ -620,11 +603,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
else -> return@NavigationButton
}
},
),
),
),
)
}.launchIn(modelScope)
}
private fun getPrimaryButtonText(confirmUM: ConfirmUM, isHoldToConfirm: Boolean): TextReference {

View file

@ -1,8 +1,7 @@
package com.tangem.features.swap.v2.impl.sendviaswap.entity
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.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
@ -11,5 +10,4 @@ internal data class SendWithSwapUM(
val destinationUM: DestinationUM,
val feeSelectorUM: FeeSelectorUM,
val confirmUM: ConfirmUM,
val navigationUM: NavigationUM,
)

View file

@ -1,10 +1,10 @@
package com.tangem.features.swap.v2.impl.sendviaswap.model
import arrow.core.getOrElse
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
@ -20,9 +20,9 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
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.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponent
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
@ -61,7 +61,6 @@ internal class SendWithSwapModel @Inject constructor(
val analyticCategoryName = CommonSendAnalyticEvents.SEND_CATEGORY
val analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.SendWithSwap
val initialRoute = SendWithSwapRoute.Amount(false)
val currentRoute = MutableStateFlow<SendWithSwapRoute>(initialRoute)
var userWallet: UserWallet by Delegates.notNull()
var appCurrency: AppCurrency = AppCurrency.Default
@ -108,15 +107,9 @@ internal class SendWithSwapModel @Inject constructor(
uiState.update { it.copy(destinationUM = destinationUM) }
}
override fun onResult(route: SendWithSwapRoute, sendWithSwapUM: SendWithSwapUM) {
if (currentRoute.value == route) {
override fun onResult(sendWithSwapUM: SendWithSwapUM) {
uiState.value = sendWithSwapUM
}
}
override fun onNavigationResult(navigationUM: NavigationUM) {
uiState.update { it.copy(navigationUM = navigationUM) }
}
override fun onSeparatorClick(lastAmount: String, isEnterInFiatSelected: Boolean) {
params.callback?.onCloseSwap(lastAmount, isEnterInFiatSelected)
@ -129,7 +122,6 @@ internal class SendWithSwapModel @Inject constructor(
destinationUM = DestinationUM.Empty(),
feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
)
}
if (resetNavigation) {
@ -137,17 +129,17 @@ internal class SendWithSwapModel @Inject constructor(
}
}
override fun onBackClick() = router.pop()
override fun onBackClick(currentRoute: Route) = router.pop()
override fun onNextClick() {
if (currentRoute.value.isEditMode) {
onBackClick()
override fun onNextClick(currentRoute: Route) {
if ((currentRoute as? SendWithSwapRoute)?.isEditMode == true) {
onBackClick(currentRoute)
} else {
when (currentRoute.value) {
when (currentRoute) {
is SendWithSwapRoute.Amount -> router.push(SendWithSwapRoute.Destination(isEditMode = false))
is SendWithSwapRoute.Destination -> router.push(SendWithSwapRoute.Confirm)
SendWithSwapRoute.Confirm -> router.push(SendWithSwapRoute.Success)
SendWithSwapRoute.Success -> onBackClick()
SendWithSwapRoute.Success -> onBackClick(currentRoute)
}
}
}
@ -171,7 +163,7 @@ internal class SendWithSwapModel @Inject constructor(
)
}
},
popBack = ::onBackClick,
popBack = router::pop,
)
},
)
@ -189,7 +181,6 @@ internal class SendWithSwapModel @Inject constructor(
destinationUM = DestinationUM.Empty(),
feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
)
}

View file

@ -1,37 +1,91 @@
package com.tangem.features.swap.v2.impl.sendviaswap.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.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
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.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.features.swap.v2.impl.sendviaswap.success.model.SendWithSwapSuccessModel
import com.tangem.features.swap.v2.impl.sendviaswap.success.ui.SendWithSwapSuccessContent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal class SendWithSwapSuccessComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val params: Params,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SendWithSwapSuccessModel = getOrCreateModel(params = params)
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
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()
SendWithSwapSuccessContent(sendWithSwapUM = state)
}
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
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,
)).takeUnless { (state.confirmUM as? ConfirmUM.Success)?.txUrl.isNullOrBlank() },
)
PrimaryButton(
text = stringResourceSafe(R.string.common_close),
onClick = router::pop,
modifier = Modifier.fillMaxWidth(),
)
}
}
data class Params(
val sendWithSwapUMFlow: StateFlow<SendWithSwapUM>,
val analyticsCategoryName: String,
val currentRoute: Flow<SendWithSwapRoute.Success>,
val callback: NavigationModelCallback,
)
}

View file

@ -1,18 +1,11 @@
package com.tangem.features.swap.v2.impl.sendviaswap.success.model
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.features.swap.v2.impl.sendviaswap.success.SendWithSwapSuccessComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -24,7 +17,6 @@ internal class SendWithSwapSuccessModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
private val appRouter: AppRouter,
paramsContainer: ParamsContainer,
) : Model() {
@ -34,47 +26,14 @@ internal class SendWithSwapSuccessModel @Inject constructor(
val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success
init {
configConfirmSuccessNavigation()
}
private fun configConfirmSuccessNavigation() {
params.callback.onNavigationResult(
NavigationUM.Content(
source = SendWithSwapRoute.Success.javaClass.simpleName,
title = TextReference.EMPTY,
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = 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,
)).takeUnless { confirmUM?.txUrl.isNullOrBlank() },
),
)
}
private fun onExploreClick() {
fun onExploreClick() {
if (confirmUM == null) return
// analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName))
urlOpener.openUrl(confirmUM.txUrl)
}
private fun onShareClick() {
fun onShareClick() {
if (confirmUM == null) return
// analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName))

View file

@ -21,9 +21,6 @@ import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.common.ui.navigationButtons.NavigationButton
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.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.icons.identicon.IdentIcon
@ -45,27 +42,24 @@ import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.swap.models.SwapDataModel
import com.tangem.domain.swap.models.SwapDataTransactionModel
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
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.DestinationTextFieldUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Composable
internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) {
if (sendWithSwapUM.navigationUM !is NavigationUM.Content) return
Column {
Box(
modifier = Modifier
@ -82,14 +76,6 @@ internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) {
backgroundColor = TangemTheme.colors.background.tertiary,
)
}
NavigationButtonsBlockV2(
navigationUM = sendWithSwapUM.navigationUM,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
@ -458,31 +444,6 @@ private fun SendWithSwapSuccessContent_Preview() {
isPrimaryButtonEnabled = false,
),
),
navigationUM = NavigationUM.Content(
source = SendWithSwapRoute.Success.javaClass.simpleName,
title = TextReference.EMPTY,
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = {},
additionalIconRes = null,
additionalIconClick = null,
primaryButton = NavigationButton(
textReference = resourceReference(R.string.common_close),
isEnabled = true,
onClick = {},
),
secondaryPairButtonsUM = NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
isEnabled = true,
onClick = {},
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
isEnabled = true,
onClick = {},
),
),
),
)
}

View file

@ -1,45 +1,25 @@
package com.tangem.features.swap.v2.impl.sendviaswap.ui
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
import com.arkivanov.decompose.extensions.compose.stack.animation.plus
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.common.ui.footers.SendingText
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.utils.StringsSigns
@Composable
internal fun SendWithSwapContent(
navigationUM: NavigationUM,
confirmUM: ConfirmUM,
stackState: ChildStack<SendWithSwapRoute, ComposableContentComponent>,
onLinkClick: (String) -> Unit,
) {
val navigationUMContent = navigationUM as? NavigationUM.Content ?: return
internal fun SendWithSwapContent(stackState: ChildStack<SendWithSwapRoute, ComposableModularContentComponent>) {
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
@ -48,12 +28,7 @@ internal fun SendWithSwapContent(
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarWithBackButton(
text = navigationUMContent.title.resolveReference(),
onBackClick = navigationUMContent.backIconClick,
iconRes = navigationUMContent.additionalIconRes,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
stackState.active.instance.Title()
Children(
stack = stackState,
animation = stackAnimation { child ->
@ -76,125 +51,6 @@ internal fun SendWithSwapContent(
}
}
}
if (stackState.active.configuration != SendWithSwapRoute.Success) {
SendWithSwapFooter(
confirmUM = confirmUM,
stackState = stackState,
primaryButton = navigationUMContent.primaryButton,
onLinkClick = onLinkClick,
)
}
}
}
@Composable
private fun SendWithSwapFooter(
confirmUM: ConfirmUM,
stackState: ChildStack<SendWithSwapRoute, ComposableContentComponent>,
primaryButton: NavigationButton,
onLinkClick: (String) -> Unit,
) {
Column {
AnimatedVisibility(
visible = stackState.active.configuration == SendWithSwapRoute.Confirm,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
val confirmContentUM = confirmUM as? ConfirmUM.Content
val sendFooter = confirmContentUM?.sendingFooter ?: TextReference.EMPTY
val legalFooter = getAnnotatedStringForLegals(
tosUM = confirmContentUM?.tosUM,
sendFooter = sendFooter,
onClick = onLinkClick,
)
val footerText = remember(sendFooter, legalFooter) {
if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) {
combinedReference(sendFooter, legalFooter)
} else {
TextReference.EMPTY
}
}
SendingText(footerText = footerText)
}
NavigationPrimaryButton(
primaryButton = primaryButton,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
@Composable
private fun getAnnotatedStringForLegals(
tosUM: ConfirmUM.Content.TosUM?,
sendFooter: TextReference,
onClick: (String) -> Unit,
): TextReference {
if (tosUM == null) return TextReference.EMPTY
val tos = tosUM.tosLink
val policy = tosUM.policyLink
return if (tos != null && policy != null) {
val tosTitle = tos.title.resolveReference()
val policyTitle = policy.title.resolveReference()
val fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle)
val tosIndex = fullString.indexOf(tosTitle)
val policyIndex = fullString.indexOf(policyTitle)
annotatedReference {
if (!sendFooter.resolveReference().endsWith(StringsSigns.POINT_SIGN)) {
append(StringsSigns.POINT_SIGN)
}
appendSpace()
append(fullString.substring(0, tosIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "TOS_TAG",
linkInteractionListener = { onClick(tos.link) },
),
block = {
appendColored(
text = fullString.substring(tosIndex, tosIndex + tosTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
append(fullString.substring(tosIndex + tosTitle.length, policyIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "POLICY_TAG",
linkInteractionListener = { onClick(policy.link) },
),
block = {
appendColored(
text = fullString.substring(policyIndex, policyIndex + policyTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
}
} else {
val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" }
val legalTitle = legal.title.resolveReference()
val fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legalTitle)
val legalIndex = fullString.indexOf(legalTitle)
annotatedReference {
append(fullString.substring(0, legalIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "LEGAL_TAG",
linkInteractionListener = { onClick(legal.link) },
),
block = {
appendColored(
text = fullString.substring(legalIndex, legalIndex + legalTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
}
stackState.active.instance.Footer()
}
}

View file

@ -0,0 +1,138 @@
package com.tangem.features.swap.v2.impl.sendviaswap.model
import arrow.core.left
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
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.wallets.models.errors.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.entry.SendEntryRoute
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
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.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
/**
* Guards the send-with-swap navigation refactor: [SendWithSwapModel.onBackClick] /
* [SendWithSwapModel.onNextClick] now receive the active [com.tangem.core.decompose.navigation.Route]
* as a parameter instead of reading an internal `currentRoute` StateFlow. Routing is a pure,
* synchronous decision over [SendWithSwapRoute], so each method is asserted before any advance; the
* model is then destroyed inside the test body so its `init {}` collectors (`initAppCurrency`,
* `subscribeOnBalanceHidden`) are cancelled never run before `runTest`'s terminal advance. The
* synchronous `initUserWallet()` is steered to the not-found branch so it never touches the router
* during construction.
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class SendWithSwapModelNavigationTest {
private val router: Router = mockk(relaxed = true)
private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
private val cryptoCurrency = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum)
@Test
fun `GIVEN amount step WHEN onNextClick THEN pushes destination`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Amount(isEditMode = false))
verify(exactly = 1) { router.push(SendWithSwapRoute.Destination(isEditMode = false)) }
model.onDestroy()
}
@Test
fun `GIVEN destination step WHEN onNextClick THEN pushes confirm`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Destination(isEditMode = false))
verify(exactly = 1) { router.push(SendWithSwapRoute.Confirm) }
model.onDestroy()
}
@Test
fun `GIVEN confirm step WHEN onNextClick THEN pushes success`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Confirm)
verify(exactly = 1) { router.push(SendWithSwapRoute.Success) }
model.onDestroy()
}
@Test
fun `GIVEN success step WHEN onNextClick THEN pops`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Success)
verify(exactly = 1) { router.pop() }
verify(exactly = 0) { router.push(any()) }
model.onDestroy()
}
@Test
fun `GIVEN edit-mode amount WHEN onNextClick THEN pops instead of advancing`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Amount(isEditMode = true))
verify(exactly = 1) { router.pop() }
verify(exactly = 0) { router.push(any()) }
model.onDestroy()
}
@Test
fun `GIVEN any route WHEN onBackClick THEN pops`() = runTest {
val model = createModel(this)
model.onBackClick(SendWithSwapRoute.Destination(isEditMode = false))
verify(exactly = 1) { router.pop() }
model.onDestroy()
}
private fun createModel(testScope: TestScope): SendWithSwapModel {
// Synchronous initUserWallet() runs at construction → keep it on the not-found branch (no router calls).
every { getUserWalletUseCase(any()) } returns GetUserWalletError.UserWalletNotFound.left()
val params = SendWithSwapComponent.Params(
userWalletId = UserWalletId(stringValue = "0123456789"),
currency = cryptoCurrency,
callback = null,
currentRoute = MutableStateFlow(SendEntryRoute.SendWithSwap),
)
return SendWithSwapModel(
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
router = router,
getFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true),
getUserWalletUseCase = getUserWalletUseCase,
getSelectedAppCurrencyUseCase = mockk(relaxed = true),
getBalanceHidingSettingsUseCase = mockk(relaxed = true),
getAccountCurrencyStatusUseCase = mockk(relaxed = true),
isAccountsModeEnabledUseCase = mockk(relaxed = true),
swapAlertFactory = mockk(relaxed = true),
paramsContainer = MutableParamsContainer(value = params),
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -406,6 +406,24 @@ internal data class TangemMessageBannerStory(
}
}
internal data class TextStyleStory(
val style: Style,
val textScale: Float,
val onStyleChange: (Style) -> Unit,
val onTextScaleChange: (Float) -> Unit,
) : DsStoryBookPage {
/** DS3 typography3 text styles available in the preview. Labels match the Figma naming. */
enum class Style(val label: String) {
Display("Display"),
HeadM("Head.M"),
HeadS("Head.S"),
Body("Body"),
SubH("Sub.H"),
Caption("Caption"),
}
}
internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant,
val status: TangemBadge.Status,

View file

@ -26,6 +26,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner.ta
import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSearchStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.textStyleStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.tangemTopNavigationStoryFactory
private data class DsStoryItem(val title: String, val factory: StoryPageFactory)
@ -43,6 +44,7 @@ private fun buildDsStories() = listOf(
DsStoryItem(title = "🧭 TangemTopNavigation", factory = tangemTopNavigationStoryFactory),
DsStoryItem(title = "💫 TangemGlowRing", factory = tangemGlowRingStoryFactory),
DsStoryItem(title = "📢 TangemMessageBanner", factory = tangemMessageBannerStoryFactory),
DsStoryItem(title = "🔤 Text styles", factory = textStyleStoryFactory),
)
@Composable

View file

@ -0,0 +1,21 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.textstyle
import com.tangem.feature.tester.presentation.storybook.entity.TextStyleStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TextStyleStory>.build(): TextStyleStory {
return TextStyleStory(
style = TextStyleStory.Style.HeadM,
textScale = 1f,
onStyleChange = { style ->
updateStory { it.copy(style = style) }
},
onTextScaleChange = { scale ->
updateStory { it.copy(textScale = scale) }
},
)
}
internal val textStyleStoryFactory
get() = storyPageFactory(StateUpdater<TextStyleStory>::build)

View file

@ -0,0 +1,192 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.textstyle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TextStyleStory
@Composable
internal fun TextStyleStory(state: TextStyleStory, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.statusBarsPadding()
.fillMaxSize()
.background(TangemTheme.colors2.surface.level1),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
ComponentPreview(style = state.style, textScale = state.textScale)
StyleSelector(selected = state.style, onSelect = state.onStyleChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
}
}
@Composable
private fun ComponentPreview(style: TextStyleStory.Style, textScale: Float) {
val textStyle = style.toTextStyle()
val baseDensity = LocalDensity.current
val scaledDensity = remember(baseDensity, textScale) {
Density(density = baseDensity.density, fontScale = textScale)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors2.surface.level2)
.padding(24.dp),
) {
CompositionLocalProvider(LocalDensity provides scaledDensity) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
SampleText(text = "multiline\nstring", style = textStyle)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
SampleText(text = "one string", style = textStyle)
SampleText(text = "separate string", style = textStyle)
}
}
}
}
}
@Composable
private fun SampleText(text: String, style: TextStyle) {
Text(
text = text,
style = style,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
)
}
@Composable
private fun StyleSelector(selected: TextStyleStory.Style, onSelect: (TextStyleStory.Style) -> Unit) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = "Text style",
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(TangemTheme.colors2.surface.level2)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.secondary,
shape = shape,
)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
TextStyleStory.Style.entries.forEach { style ->
StyleChip(
label = style.label,
selected = style == selected,
onClick = { onSelect(style) },
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun TextScaleSlider(value: Float, onChange: (Float) -> Unit) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = "Text scale: ${"%.2f".format(value)}x",
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Slider(
modifier = Modifier.fillMaxWidth(),
value = value,
onValueChange = onChange,
valueRange = 0.5f..2f,
steps = 14,
colors = SliderDefaults.colors(
thumbColor = TangemTheme.colors.text.accent,
activeTrackColor = TangemTheme.colors.text.accent,
activeTickColor = TangemTheme.colors2.surface.level3,
inactiveTrackColor = TangemTheme.colors2.surface.level3,
inactiveTickColor = TangemTheme.colors.text.accent,
),
)
}
}
@Composable
private fun StyleChip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val chipShape = RoundedCornerShape(50)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(chipShape)
.background(
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
Text(
text = label,
style = TangemTheme.typography.caption2,
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
)
}
}
@Composable
@ReadOnlyComposable
private fun TextStyleStory.Style.toTextStyle(): TextStyle = when (this) {
TextStyleStory.Style.Display -> TangemTheme.typography3.display.medium
TextStyleStory.Style.HeadM -> TangemTheme.typography3.heading.medium
TextStyleStory.Style.HeadS -> TangemTheme.typography3.heading.small
TextStyleStory.Style.Body -> TangemTheme.typography3.body.medium
TextStyleStory.Style.SubH -> TangemTheme.typography3.subheading.medium
TextStyleStory.Style.Caption -> TangemTheme.typography3.caption.medium
}

View file

@ -48,6 +48,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner.Ta
import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory
import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSearchStory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory
import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.TextStyleStory
import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.TangemTopNavigationStory
import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory
import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory
@ -105,6 +106,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
is TangemFadeStory -> TangemFadeStory(state = storyState)
is TangemTopNavigationStory -> TangemTopNavigationStory(state = storyState)
is TangemMessageBannerStory -> TangemMessageBannerStory(state = storyState)
is TextStyleStory -> TextStyleStory(state = storyState)
}
}
}

View file

@ -67,7 +67,6 @@ internal class WalletTokensListUMConverterTest {
isAccountsModeEnabled = false,
expandedAccounts = emptySet(),
stakingAvailabilityMap = emptyMap(),
isAddAndManageTokensEnabled = true,
shouldShowMainPromo = false,
)