Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 14:50:24 +03:00
commit 50fe9e603d
1919 changed files with 94940 additions and 15178 deletions

View file

@ -1,6 +1,16 @@
package com.tangem.features.send
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.send.api.SendFeatureToggles
import javax.inject.Inject
internal class DefaultSendFeatureToggles @Inject constructor() : SendFeatureToggles
internal class DefaultSendFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : SendFeatureToggles {
override val isHighFeeWarningEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.TWI_1367_HIGH_FEE_WARNING_ENABLED,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.send.common
import com.tangem.core.decompose.navigation.Route
import com.tangem.features.send.api.subcomponents.amount.AmountRoute
import com.tangem.features.send.api.subcomponents.destination.DestinationRoute
import kotlinx.serialization.Serializable
@ -15,7 +16,7 @@ internal sealed class CommonSendRoute : Route {
@Serializable
data object Confirm : CommonSendRoute() {
override val isEditMode: Boolean = true
override val isEditMode: Boolean = false
}
@Serializable
@ -31,5 +32,5 @@ internal sealed class CommonSendRoute : Route {
@Serializable
data class Amount(
override val isEditMode: Boolean,
) : CommonSendRoute()
) : CommonSendRoute(), AmountRoute
}

View file

@ -15,7 +15,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.impl.R
@Composable

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

@ -5,7 +5,7 @@ import com.tangem.features.send.api.NFTSendComponent
import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.SendEntryPointComponent
import com.tangem.features.send.api.SendFeatureToggles
import com.tangem.features.send.api.SendNotificationsComponent
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.entrypoint.DefaultSendEntryPointComponent
import com.tangem.features.send.send.DefaultSendComponent
import com.tangem.features.send.sendnft.DefaultNFTSendComponent

View file

@ -10,7 +10,7 @@ import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entry.SendEntryRoute
import com.tangem.features.send.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider

View file

@ -12,10 +12,10 @@ 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.extensions.conditional
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.FeeSelectorComponent
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorComponent
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.feeselector.model.FeeSelectorBlockModel
import com.tangem.features.send.feeselector.ui.FeeSelectorBlockContent
import com.tangem.utils.extensions.isSingleItem

View file

@ -12,8 +12,8 @@ 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.features.send.api.FeeSelectorComponent
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorComponent
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.feeselector.component.extended.FeeExtendedSelectorComponent
import com.tangem.features.send.feeselector.component.speed.FeeSpeedSelectorComponent

View file

@ -1,7 +1,7 @@
package com.tangem.features.send.feeselector.component
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.feeselector.model.FeeSelectorIntents
import kotlinx.coroutines.flow.MutableStateFlow

View file

@ -2,8 +2,8 @@ package com.tangem.features.send.feeselector.component.extended.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
@Immutable
data class FeeExtendedSelectorUM(

View file

@ -8,7 +8,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.feeselector.component.extended.entity.FeeExtendedSelectorUM
import com.tangem.features.send.feeselector.route.FeeSelectorRoute

View file

@ -33,11 +33,11 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.send.api.entity.FeeExtraInfo
import com.tangem.features.send.api.entity.FeeFiatRateUM
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM
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.feeselector.component.extended.entity.FeeExtendedSelectorUM
import com.tangem.features.send.feeselector.component.speed.ui.RegularFeeItemContent
import kotlinx.collections.immutable.persistentListOf

View file

@ -7,7 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.feeselector.component.speed.model.FeeSpeedSelectorModel
import com.tangem.features.send.feeselector.component.speed.ui.FeeSpeedSelectorContent

View file

@ -6,7 +6,7 @@ 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.url.UrlOpener
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.feeselector.component.speed.FeeSpeedSelectorIntents
import com.tangem.features.send.feeselector.model.FeeSelectorIntents

View file

@ -54,12 +54,12 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.entity.FeeExtraInfo
import com.tangem.features.send.api.entity.FeeFiatRateUM
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM
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.feeselector.component.speed.FeeSpeedSelectorIntents
import com.tangem.features.send.feeselector.component.speed.StubFeeSpeedSelectorIntents
import com.tangem.features.send.impl.R

View file

@ -1,6 +1,6 @@
package com.tangem.features.send.feeselector.component.token.entity
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import kotlinx.collections.immutable.ImmutableList
internal data class FeeTokenSelectorUM(

View file

@ -10,7 +10,7 @@ import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.feeselector.component.token.FeeTokenSelectorIntents
import com.tangem.features.send.feeselector.component.token.entity.FeeTokenItemState

View file

@ -36,11 +36,11 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.send.api.entity.FeeExtraInfo
import com.tangem.features.send.api.entity.FeeFiatRateUM
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM
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.feeselector.component.token.FeeTokenSelectorIntents
import com.tangem.features.send.feeselector.component.token.StubFeeTokenSelectorIntents
import com.tangem.features.send.feeselector.component.token.entity.FeeTokenItemState

View file

@ -1,7 +1,7 @@
package com.tangem.features.send.feeselector.di
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.FeeSelectorComponent
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorComponent
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.FeeSelectorReloadListener

View file

@ -7,8 +7,8 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import com.tangem.features.send.impl.R
import java.math.BigDecimal

View file

@ -13,9 +13,9 @@ import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
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.feeSelector.params.FeeSelectorParams
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

View file

@ -2,7 +2,7 @@ package com.tangem.features.send.feeselector.model
import androidx.compose.runtime.Stable
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
@Stable
internal interface FeeSelectorIntents {

View file

@ -21,10 +21,10 @@ import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCas
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.NonceInserted
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
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.feeSelector.params.FeeSelectorParams
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.FeeSelectorReloadListener

View file

@ -10,9 +10,9 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.utils.stack
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.feeselector.route.FeeSelectorRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.isSingleItem

View file

@ -4,8 +4,8 @@ import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.feeselector.model.FeeSelectorIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList

View file

@ -1,7 +1,7 @@
package com.tangem.features.send.feeselector.model.transformers
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
internal class FeeItemSelectedTransformer(private val selectedFeeItem: FeeItem) : Transformer<FeeSelectorUM> {

View file

@ -5,8 +5,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.subcomponents.fee.model.converters.custom.bitcoin.BitcoinCustomFeeConverter
import com.tangem.features.send.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter

View file

@ -3,8 +3,8 @@ package com.tangem.features.send.feeselector.model.transformers
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.feeselector.model.FeeSelectorIntents
import com.tangem.utils.extensions.isZero
import com.tangem.utils.transformer.Transformer

View file

@ -1,7 +1,7 @@
package com.tangem.features.send.feeselector.model.transformers
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
internal class FeeSelectorErrorTransformer(private val error: GetFeeError) : Transformer<FeeSelectorUM> {

View file

@ -4,12 +4,12 @@ import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.FeeExtraInfo
import com.tangem.features.send.api.entity.FeeFiatRateUM
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM
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.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.feeselector.model.FeeSelectorLogic
import com.tangem.lib.crypto.BlockchainUtils.isTron

View file

@ -1,6 +1,6 @@
package com.tangem.features.send.feeselector.model.transformers
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
internal object FeeSelectorLoadingTransformer : Transformer<FeeSelectorUM> {

View file

@ -1,7 +1,7 @@
package com.tangem.features.send.feeselector.model.transformers
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
internal class FeeSelectorNonceChangeTransformer(

View file

@ -1,7 +1,7 @@
package com.tangem.features.send.feeselector.model.transformers
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList

View file

@ -1,8 +1,8 @@
package com.tangem.features.send.feeselector.model.transformers
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf

View file

@ -46,11 +46,11 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.api.entity.FeeExtraInfo
import com.tangem.features.send.api.entity.FeeFiatRateUM
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM
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.impl.R
import com.tangem.utils.extensions.isSingleItem
import kotlinx.collections.immutable.persistentListOf

View file

@ -20,9 +20,9 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWi
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.feeselector.route.FeeSelectorRoute
import com.tangem.features.send.impl.R

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,45 +11,44 @@ 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.FeeSelectorBlockComponent
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.SendAmountComponent
import com.tangem.features.send.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent
import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationComponent
import com.tangem.features.send.impl.R
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterIsInstance
@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,
) : SendComponent, AppComponentContext by appComponentContext {
@ -106,7 +99,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
type = model.consumeEntryType(),
),
)
if (model.currentRoute.value.isEditMode) {
if (childStack.value.active.configuration.isEditMode) {
activeComponent.updateState(model.uiState.value)
}
}
@ -120,7 +113,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
)
activeComponent.updateState(model.uiState.value.amountUM)
}
is DefaultSendDestinationComponent -> {
is SendDestinationComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AddressScreenOpened(
categoryName = model.analyticCategoryName,
@ -130,41 +123,37 @@ internal class DefaultSendComponent @AssistedInject constructor(
activeComponent.updateState(model.uiState.value.destinationUM)
}
}
model.currentRoute.value = 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): DefaultSendDestinationComponent =
DefaultSendDestinationComponent(
appComponentContext = factoryContext,
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,
@ -174,13 +163,17 @@ internal class DefaultSendComponent @AssistedInject constructor(
callback = model,
),
)
}
private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent {
return SendAmountComponent(
appComponentContext = factoryContext,
private fun getAmountComponent(
route: AmountRoute,
factoryContext: AppComponentContext,
): ComposableModularContentComponent {
return amountComponentFactory.create(
context = factoryContext,
params = SendAmountComponentParams.AmountParams(
state = model.uiState.value.amountUM,
currentRoute = model.currentRoute.asStateFlow(),
route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName,
appCurrency = model.appCurrency,
@ -196,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
@ -205,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,
@ -228,78 +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,
),
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

@ -8,8 +8,8 @@ import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCa
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.derivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
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.send.send.ui.state.SendUM

View file

@ -1,35 +1,45 @@
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
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.SendNotificationsComponent
import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.common.CommonSendRoute
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.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.SendAmountBlockComponent
import com.tangem.features.send.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent
import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent
import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent
import com.tangem.utils.extensions.orZero
@ -39,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)
@ -61,7 +71,7 @@ internal class SendConfirmComponent(
onClick = model::showEditDestination,
)
private val amountBlockComponent = SendAmountBlockComponent(
private val amountBlockComponent = DefaultSendAmountBlockComponent(
appComponentContext = child("sendConfirmAmountBlock"),
params = SendAmountComponentParams.AmountBlockParams(
state = model.uiState.value.amountUM,
@ -107,7 +117,7 @@ internal class SendConfirmComponent(
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
appCurrency = params.appCurrency,
callback = model,
notificationData = NotificationData(
notificationData = SendNotificationsComponent.Params.NotificationData(
destinationAddress = model.confirmData.enteredDestination.orEmpty(),
memo = model.confirmData.enteredMemo,
amountValue = model.confirmData.enteredAmount.orZero(),
@ -134,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()
@ -149,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,
@ -162,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

@ -1,9 +1,11 @@
package com.tangem.features.send.send.confirm.model
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.transaction.error.GetFeeError
import java.math.BigDecimal
@Immutable
data class ConfirmData(
val enteredAmount: BigDecimal?,
val reduceAmountBy: BigDecimal,

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
@ -34,6 +33,7 @@ import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
@ -43,24 +43,27 @@ import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.api.SendNotificationsComponent
import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.api.SendFeatureToggles
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.params.FeeSelectorParams.FeeStateConfiguration
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger
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.feeSelector.entity.FeeNonce
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkAndCalculateSubtractedAmount
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.send.analytics.SendAnalyticHelper
import com.tangem.features.send.send.confirm.SendConfirmComponent
import com.tangem.features.send.send.confirm.model.transformers.SendConfirmInitialStateTransformer
@ -68,8 +71,6 @@ import com.tangem.features.send.send.confirm.model.transformers.SendConfirmSendi
import com.tangem.features.send.send.confirm.model.transformers.SendConfirmSentStateTransformer
import com.tangem.features.send.send.confirm.model.transformers.SendConfirmationNotificationsTransformerV2
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.extensions.stripZeroPlainString
@ -79,7 +80,7 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
import com.tangem.features.send.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM as FeeSelectorUMRedesigned
@Suppress("LongParameterList", "LargeClass")
@Stable
@ -113,6 +114,8 @@ internal class SendConfirmModel @Inject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val currenciesRepository: CurrenciesRepository,
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
private val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase,
private val sendFeatureToggles: SendFeatureToggles,
sendBalanceUpdaterFactory: SendBalanceUpdater.Factory,
) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback {
@ -156,7 +159,6 @@ internal class SendConfirmModel @Inject constructor(
init {
updateAmountSubtractAvailability()
configConfirmNavigation()
subscribeOnNotificationsUpdateTrigger()
subscribeOnCheckFeeResultUpdates()
initialState()
@ -299,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
@ -503,6 +516,7 @@ internal class SendConfirmModel @Inject constructor(
private fun updateConfirmNotifications() {
modelScope.launch {
val feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment()
notificationsUpdateTrigger.triggerUpdate(
data = NotificationData(
destinationAddress = confirmData.enteredDestination.orEmpty(),
@ -512,9 +526,10 @@ internal class SendConfirmModel @Inject constructor(
isIgnoreReduce = confirmData.isIgnoreReduce,
fee = confirmData.fee,
feeError = confirmData.feeError,
feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment(),
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
),
)
val isHighNetworkFee = isHighNetworkFee(feeCryptoCurrencyStatus.currency)
val balance = cryptoCurrencyStatus.value.amount.orZero()
val feeValue = confirmData.fee?.amount?.value.orZero()
val isTotalSendingMoreThanBalance = confirmData.enteredAmount.orZero() + feeValue > balance
@ -531,6 +546,7 @@ internal class SendConfirmModel @Inject constructor(
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
isHighNetworkFee = isHighNetworkFee,
isFeeSubtractedFromAmount = isFeeSubtractedFromAmount,
isFeeExceedingBalance = isFeeExceedingBalance,
).transform(uiState.value.confirmUM),
@ -539,58 +555,13 @@ internal class SendConfirmModel @Inject constructor(
}
}
@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 suspend fun isHighNetworkFee(feeCurrency: CryptoCurrency): Boolean {
if (!sendFeatureToggles.isHighFeeWarningEnabled) return false
val feeAmount = confirmData.fee?.amount?.value ?: return false
return isHighNetworkFeeUseCase(feeCurrency, feeAmount)
}
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

@ -13,7 +13,7 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import com.tangem.features.send.api.utils.formatFooterFiatFee
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText
@ -31,6 +31,7 @@ internal class SendConfirmationNotificationsTransformerV2(
private val cryptoCurrency: CryptoCurrency,
private val appCurrency: AppCurrency,
private val analyticsCategoryName: String,
private val isHighNetworkFee: Boolean = false,
private val isFeeSubtractedFromAmount: Boolean,
private val isFeeExceedingBalance: Boolean,
) : Transformer<ConfirmUM> {
@ -42,10 +43,17 @@ internal class SendConfirmationNotificationsTransformerV2(
notifications = buildList {
addTooHighNotification(feeSelectorUM)
addTooLowNotification(feeSelectorUM)
addHighNetworkFeeNotification()
}.toPersistentList(),
)
}
private fun MutableList<NotificationUM>.addHighNetworkFeeNotification() {
if (isHighNetworkFee) {
add(NotificationUM.Warning.HighNetworkFee)
}
}
private fun MutableList<NotificationUM>.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) {
if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM)) {
add(NotificationUM.Warning.FeeTooLow)

View file

@ -14,11 +14,11 @@ import androidx.compose.ui.unit.dp
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.common.ui.tapHelp
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.subcomponents.amount.SendAmountBlockComponent
import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent
import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent
import com.tangem.features.send.subcomponents.notifications
import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent
@ -31,7 +31,7 @@ private const val BLOCKS_KEY = "BLOCKS_KEY"
internal fun SendConfirmContent(
sendUM: SendUM,
destinationBlockComponent: DefaultSendDestinationBlockComponent,
amountBlockComponent: SendAmountBlockComponent,
amountBlockComponent: DefaultSendAmountBlockComponent,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
notificationsComponent: DefaultSendNotificationsComponent,
notificationsUM: ImmutableList<NotificationUM>,
@ -73,7 +73,7 @@ internal fun SendConfirmContent(
private fun LazyListScope.blocks(
destinationBlockComponent: DefaultSendDestinationBlockComponent,
amountBlockComponent: SendAmountBlockComponent,
amountBlockComponent: DefaultSendAmountBlockComponent,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
) {
item(key = BLOCKS_KEY) {

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
@ -41,16 +41,18 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.models.errors.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.entity.isFromMainScreenQr
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger
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
@ -58,8 +60,6 @@ import com.tangem.features.send.send.analytics.SendAnalyticEvents
import com.tangem.features.send.send.confirm.SendConfirmComponent
import com.tangem.features.send.send.success.SendConfirmSuccessComponent
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationInitialStateTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
@ -123,8 +123,6 @@ internal class SendModel @Inject constructor(
CommonSendRoute.Empty
}
val currentRoute = MutableStateFlow(initialRoute)
val cryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>
field = MutableStateFlow(
CryptoCurrencyStatus(
@ -149,7 +147,7 @@ internal class SendModel @Inject constructor(
return cryptoCurrencyStatus.isAvailableForSend() && feeCryptoCurrencyStatus.isAvailableForSend()
}
val isUnavailableForSend: Boolean
private val isUnavailableForSend: Boolean
get() {
val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value
val feeCryptoCurrencyStatus = feeCryptoCurrencyStatusFlow.value
@ -179,10 +177,6 @@ internal class SendModel @Inject constructor(
initAppCurrency()
}
override fun onNavigationResult(navigationUM: NavigationUM) {
uiState.update { it.copy(navigationUM = navigationUM) }
}
override fun onDestinationResult(destinationUM: DestinationUM) {
uiState.update { it.copy(destinationUM = destinationUM) }
}
@ -196,9 +190,9 @@ internal class SendModel @Inject constructor(
uiState.update { sendUM }
}
override fun onBackClick() {
when (val route = currentRoute.value) {
is CommonSendRoute.Amount -> if (!route.isEditMode) {
override fun onBackClick(currentRoute: Route) {
when (currentRoute) {
is CommonSendRoute.Amount -> if (!currentRoute.isEditMode) {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticCategoryName,
@ -208,7 +202,7 @@ internal class SendModel @Inject constructor(
),
)
}
is CommonSendRoute.Destination -> if (!route.isEditMode) {
is CommonSendRoute.Destination -> if (!currentRoute.isEditMode) {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticCategoryName,
@ -224,11 +218,11 @@ internal class SendModel @Inject constructor(
router.pop()
}
override fun onNextClick() {
if (currentRoute.value.isEditMode) {
onBackClick()
override fun onNextClick(currentRoute: Route) {
if ((currentRoute as? CommonSendRoute)?.isEditMode == true) {
onBackClick(currentRoute)
} else {
when (currentRoute.value) {
when (currentRoute) {
is CommonSendRoute.Amount -> {
val nextRoute = if (predefinedValues.isFromMainScreenQr) {
CommonSendRoute.Confirm
@ -239,7 +233,7 @@ internal class SendModel @Inject constructor(
}
is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm)
CommonSendRoute.Confirm -> router.push(CommonSendRoute.ConfirmSuccess)
else -> onBackClick()
else -> router.pop()
}
}
}
@ -263,7 +257,6 @@ internal class SendModel @Inject constructor(
feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty,
confirmData = null,
navigationUM = NavigationUM.Empty,
)
}
router.popTo(CommonSendRoute.Amount(isEditMode = false))
@ -442,7 +435,7 @@ internal class SendModel @Inject constructor(
cryptoCurrencyStatusFlow,
feeCryptoCurrencyStatusFlow,
) { cryptoCurrencyStatus, _ ->
if (!isAvailableForSend || currentRoute.value != initialRoute) {
if (!isAvailableForSend) {
if (isUnavailableForSend) showAlertError()
return@combine
}
@ -548,7 +541,6 @@ internal class SendModel @Inject constructor(
cryptoCurrency = cryptoCurrency,
).transform(DestinationUM.Empty()),
confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
confirmData = null,
feeSelectorUM = FeeSelectorUM.Loading,
)

View file

@ -1,27 +1,77 @@
package com.tangem.features.send.send.success
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.navigationButtons.DoneButtons
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.send.impl.R
import com.tangem.features.send.send.success.model.SendConfirmSuccessModel
import com.tangem.features.send.send.success.ui.SendConfirmSuccessContent
import com.tangem.features.send.send.ui.state.SendUM
import kotlinx.coroutines.flow.Flow
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
internal class SendConfirmSuccessComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
internal class SendConfirmSuccessComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: Params,
destinationBlockComponentFactory: SendDestinationBlockComponent.Factory,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SendConfirmSuccessModel = getOrCreateModel(params = params)
private val destinationBlockComponent: SendDestinationBlockComponent = params.destinationBlockComponent
private val destinationBlockComponent: SendDestinationBlockComponent = destinationBlockComponentFactory.create(
context = child("sendConfirmDestinationBlock"),
params = SendDestinationComponentParams.DestinationBlockParams(
state = model.uiState.value.destinationUM,
analyticsCategoryName = params.analyticsCategoryName,
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
blockClickEnableFlow = MutableStateFlow(true),
predefinedValues = params.predefinedValues,
isAddContactAvailable = true,
),
onResult = {},
onClick = {},
)
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
onBackClick = {
model.onBackClick()
router.pop()
},
backIconRes = R.drawable.ic_close_24,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@Composable
override fun Content(modifier: Modifier) {
@ -32,11 +82,43 @@ internal class SendConfirmSuccessComponent(
)
}
@Composable
override fun Footer() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
) {
DoneButtons(
(NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = model::onExploreClick,
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = model::onShareClick,
)).takeIf { params.txUrl.isNotEmpty() },
)
PrimaryButton(
text = stringResourceSafe(R.string.common_close),
onClick = router::pop,
modifier = Modifier.fillMaxWidth(),
)
}
}
data class Params(
val sendUMFlow: StateFlow<SendUM>,
val destinationBlockComponent: SendDestinationBlockComponent,
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val analyticsCategoryName: String,
val currentRoute: Flow<CommonSendRoute>,
val predefinedValues: PredefinedValues,
val txUrl: String,
val callback: ModelCallback,
)
@ -44,4 +126,9 @@ internal class SendConfirmSuccessComponent(
interface ModelCallback {
fun onResult(sendUM: SendUM)
}
@AssistedFactory
interface Factory {
fun create(appComponentContext: AppComponentContext, params: Params): SendConfirmSuccessComponent
}
}

View file

@ -1,28 +1,17 @@
package com.tangem.features.send.send.success.model
import androidx.compose.runtime.Stable
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.send.success.SendConfirmSuccessComponent
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@Stable
@ -31,7 +20,6 @@ internal class SendConfirmSuccessModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
) : Model() {
@ -39,73 +27,23 @@ internal class SendConfirmSuccessModel @Inject constructor(
private val _uiState = params.sendUMFlow
val uiState = _uiState
init {
configConfirmSuccessNavigation()
fun onBackClick() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
}
private fun configConfirmSuccessNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { (state, route) ->
// Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh
// lambdas every time, so the SendUM written back via callback.onResult is never equal to the
// previous one — without this guard the combine re-triggers itself endlessly and the success
// screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY].
route is CommonSendRoute.ConfirmSuccess &&
(state.navigationUM as? NavigationUM.Content)?.source !=
CommonSendRoute.ConfirmSuccess.javaClass.simpleName
}.onEach { (state, _) ->
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
source = CommonSendRoute.ConfirmSuccess.javaClass.simpleName,
title = stringReference(""),
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
appRouter.pop()
},
primaryButton = NavigationButton(
textReference = resourceReference(R.string.common_close),
iconRes = null,
isEnabled = true,
isHapticClick = false,
onClick = {
appRouter.pop()
},
),
prevButton = null,
secondaryPairButtonsUM = (NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = ::onExploreClick,
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = ::onShareClick,
)).takeIf { params.txUrl.isNotEmpty() },
),
),
)
}.launchIn(modelScope)
}
private fun onExploreClick() {
fun onExploreClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName))
urlOpener.openUrl(params.txUrl)
}
private fun onShareClick() {
fun onShareClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName))
shareManager.shareText(params.txUrl)
}

View file

@ -11,7 +11,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2
import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
@ -25,8 +24,8 @@ import com.tangem.core.ui.utils.toTimeFormat
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.send.ui.state.SendUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.send.ui.state.SendUM
@Composable
internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent: SendDestinationBlockComponent) {
@ -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.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,28 +15,28 @@ 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
import com.tangem.features.send.sendnft.model.NFTSendModel
import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent
import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationComponent
import com.tangem.features.send.impl.R
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,
@ -76,37 +75,35 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
lifecycle = lifecycle,
mode = ObserveLifecycleMode.CREATE_DESTROY,
) { stack ->
componentScope.launch {
when (val activeComponent = stack.active.instance) {
is NFTSendConfirmComponent -> {
val fromCurrency = model.cryptoCurrency
val fromDerivationIndex = model.account?.derivationIndex?.value
.takeIf { model.isAccountsMode }
analyticsEventHandler.send(
CommonSendAnalyticEvents.ConfirmationScreenOpened(
categoryName = analyticsCategoryName,
source = analyticsSendSource,
sendBlockchain = fromCurrency.network.name,
sendToken = fromCurrency.symbol,
fromDerivationIndex = fromDerivationIndex,
toDerivationIndex = null,
),
)
if (model.currentRouteFlow.value.isEditMode) {
activeComponent.updateState(model.uiState.value)
}
}
is DefaultSendDestinationComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AddressScreenOpened(
categoryName = analyticsCategoryName,
source = analyticsSendSource,
),
)
activeComponent.updateState(model.uiState.value.destinationUM)
when (val activeComponent = stack.active.instance) {
is NFTSendConfirmComponent -> {
val fromCurrency = model.cryptoCurrency
val fromDerivationIndex = model.account?.derivationIndex?.value
.takeIf { model.isAccountsMode }
analyticsEventHandler.send(
CommonSendAnalyticEvents.ConfirmationScreenOpened(
categoryName = analyticsCategoryName,
source = analyticsSendSource,
sendBlockchain = fromCurrency.network.name,
sendToken = fromCurrency.symbol,
fromDerivationIndex = fromDerivationIndex,
toDerivationIndex = null,
),
)
// Push current state into a reused Confirm on (re)entry. Confirm.isEditMode is `true`
if (stack.active.configuration.isEditMode) {
activeComponent.updateState(model.uiState.value)
}
}
model.currentRouteFlow.emit(stack.active.configuration)
is SendDestinationComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AddressScreenOpened(
categoryName = analyticsCategoryName,
source = analyticsSendSource,
),
)
activeComponent.updateState(model.uiState.value.destinationUM)
}
}
}
}
@ -114,67 +111,71 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val stackState by childStack.subscribeAsState()
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler(onBack = model::onBackClick)
SendContent(
navigationUM = state.navigationUM,
confirmUM = state.confirmUM,
stackState = stackState,
)
BackHandler(onBack = ::onChildBack)
SendModularContent(stackState = stackState)
}
private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) {
is CommonSendRoute.Destination -> getDestinationComponent(factoryContext)
private fun createChild(
route: CommonSendRoute,
factoryContext: AppComponentContext,
): ComposableModularContentComponent = when (route) {
is CommonSendRoute.Destination -> getDestinationComponent(route, factoryContext)
CommonSendRoute.Confirm -> getConfirmComponent(factoryContext)
CommonSendRoute.ConfirmSuccess -> getSuccessComponent(factoryContext)
else -> getStubComponent()
// Empty is the bootstrap placeholder until the currency status resolves; NFT has no Amount step.
CommonSendRoute.Empty,
is CommonSendRoute.Amount,
-> ComposableModularContentComponent.EMPTY
}
private fun getDestinationComponent(factoryContext: AppComponentContext): DefaultSendDestinationComponent =
DefaultSendDestinationComponent(
appComponentContext = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = model.currentRouteFlow.filterIsInstance<CommonSendRoute.Destination>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
title = resourceReference(R.string.nft_send),
analyticsCategoryName = analyticsCategoryName,
analyticsSendSource = analyticsSendSource,
userWalletId = params.userWalletId,
cryptoCurrency = model.cryptoCurrency,
callback = model,
),
)
private fun getConfirmComponent(factoryContext: AppComponentContext) = nftSendConfirmComponentFactory.create(
appComponentContext = factoryContext,
params = NFTSendConfirmComponent.Params(
state = model.uiState.value,
analyticsCategoryName = analyticsCategoryName,
userWallet = model.userWallet,
nftAsset = params.nftAsset,
nftCollectionName = params.nftCollectionName,
cryptoCurrencyStatus = model.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus,
appCurrency = model.appCurrency,
callback = model,
currentRoute = model.currentRouteFlow.filterIsInstance<CommonSendRoute.Confirm>(),
private fun getDestinationComponent(
route: DestinationRoute,
factoryContext: AppComponentContext,
): ComposableModularContentComponent = destinationComponentFactory.create(
context = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow,
onLoadFee = model::loadFee,
title = resourceReference(R.string.nft_send),
analyticsCategoryName = analyticsCategoryName,
analyticsSendSource = analyticsSendSource,
account = model.account,
isAccountsMode = model.isAccountsMode,
onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) },
userWalletId = params.userWalletId,
cryptoCurrency = model.cryptoCurrency,
callback = model,
),
)
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent {
private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
return nftSendConfirmComponentFactory.create(
appComponentContext = factoryContext,
params = NFTSendConfirmComponent.Params(
state = model.uiState.value,
analyticsCategoryName = analyticsCategoryName,
userWallet = model.userWallet,
nftAsset = params.nftAsset,
nftCollectionName = params.nftCollectionName,
cryptoCurrencyStatus = model.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus,
appCurrency = model.appCurrency,
callback = model,
isBalanceHidingFlow = model.isBalanceHiddenFlow,
onLoadFee = model::loadFee,
analyticsSendSource = analyticsSendSource,
account = model.account,
isAccountsMode = model.isAccountsMode,
onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) },
),
)
}
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
val txUrl = (model.uiState.value.confirmUM as? ConfirmUM.Success)?.txUrl
if (txUrl == null) {
model.showAlertError()
return getStubComponent()
return ComposableModularContentComponent.EMPTY
}
return nftSendSuccessComponentFactory.create(
@ -188,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,
@ -196,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

@ -6,8 +6,8 @@ import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.Companion.NFT_SEND_CATEGORY
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
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.send.sendnft.ui.state.NFTSendUM

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.FeeSelectorBlockComponent
import com.tangem.features.send.api.SendNotificationsComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.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.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.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.SendNotificationsComponent
import com.tangem.features.send.api.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.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
@ -62,7 +61,7 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
import com.tangem.features.send.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM as FeeSelectorUMRedesigned
@Suppress("LongParameterList", "LargeClass")
@ModelScoped
@ -122,7 +121,6 @@ internal class NFTSendConfirmModel @Inject constructor(
private var sendIdleTimer: Long = 0L
init {
configConfirmNavigation()
subscribeOnNotificationsUpdateTrigger()
subscribeOnCheckFeeResultUpdates()
subscribeOnTapHelpUpdates()
@ -371,54 +369,18 @@ internal class NFTSendConfirmModel @Inject constructor(
}
}
private fun configConfirmNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).onEach { (state, _) ->
val confirmUM = state.confirmUM
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
source = CommonSendRoute.Confirm.javaClass.simpleName,
title = resourceReference(R.string.nft_send),
subtitle = null,
backIconRes = when (confirmUM) {
is ConfirmUM.Success -> R.drawable.ic_close_24
else -> R.drawable.ic_back_24
},
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = confirmUM.isPrimaryButtonEnabled,
),
)
router.pop()
},
primaryButton = primaryButtonUM(),
prevButton = null,
secondaryPairButtonsUM = (
NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = ::onExploreClick,
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = ::onShareClick,
)
).takeUnless { (confirmUM as? ConfirmUM.Success)?.txUrl.isNullOrBlank() },
),
),
)
}.launchIn(modelScope)
fun onBackClick() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = uiState.value.confirmUM.isPrimaryButtonEnabled,
),
)
}
private fun primaryButtonUM(): NavigationButton {
fun primaryButtonUM(): NavigationButton {
val confirmUM = uiState.value.confirmUM
val isContent = confirmUM is ConfirmUM.Content
val isReadyToSend = isContent && !confirmUM.isSending

View file

@ -9,7 +9,7 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import com.tangem.features.send.api.utils.formatFooterFiatFee
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText

View file

@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.nft.component.NFTDetailsBlockComponent
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.common.ui.tapHelp
import com.tangem.features.send.sendnft.ui.state.NFTSendUM

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.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)
@ -63,11 +73,25 @@ internal class NFTSendSuccessComponent @AssistedInject constructor(
cryptoCurrency = params.cryptoCurrencyStatus.currency,
blockClickEnableFlow = MutableStateFlow(false),
predefinedValues = PredefinedValues.Empty,
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) {
val state by model.uiState.collectAsStateWithLifecycle()
@ -79,11 +103,40 @@ internal class NFTSendSuccessComponent @AssistedInject constructor(
)
}
@Composable
override fun Footer() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
) {
DoneButtons(
(NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = model::onExploreClick,
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = model::onShareClick,
)).takeIf { params.txUrl.isNotEmpty() },
)
PrimaryButton(
text = stringResourceSafe(R.string.common_close),
onClick = router::pop,
modifier = Modifier.fillMaxWidth(),
)
}
}
data class Params(
val nftSendUMFlow: StateFlow<NFTSendUM>,
val analyticsCategoryName: String,
val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
val currentRoute: Flow<CommonSendRoute>,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val userWallet: UserWallet,
val nftAsset: NFTAsset,

View file

@ -1,27 +1,17 @@
package com.tangem.features.send.sendnft.success.model
import androidx.compose.runtime.Stable
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@Stable
@ -37,71 +27,23 @@ internal class NFTSendSuccessModel @Inject constructor(
val uiState = params.nftSendUMFlow
init {
configConfirmSuccessNavigation()
fun onBackClick() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
}
private fun configConfirmSuccessNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { (state, route) ->
// Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh
// lambdas every time, so the SendUM written back via callback.onResult is never equal to the
// previous one — without this guard the combine re-triggers itself endlessly and the success
// screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY].
route is CommonSendRoute.ConfirmSuccess &&
(state.navigationUM as? NavigationUM.Content)?.source !=
CommonSendRoute.ConfirmSuccess.javaClass.simpleName
}.onEach { (state, _) ->
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
source = CommonSendRoute.ConfirmSuccess.javaClass.simpleName,
title = stringReference(""),
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
params.callback.onBackClick()
},
primaryButton = NavigationButton(
textReference = resourceReference(R.string.common_close),
iconRes = null,
isEnabled = true,
isHapticClick = false,
onClick = params.callback::onBackClick,
),
prevButton = null,
secondaryPairButtonsUM = NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = ::onExploreClick,
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = ::onShareClick,
),
),
),
)
}.launchIn(modelScope)
}
private fun onExploreClick() {
fun onExploreClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName))
urlOpener.openUrl(params.txUrl)
}
private fun onShareClick() {
fun onShareClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName))
shareManager.shareText(params.txUrl)
}

View file

@ -9,7 +9,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2
import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
@ -23,8 +22,8 @@ import com.tangem.features.nft.component.NFTDetailsBlockComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.common.ui.FeeBlockSuccess
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import kotlinx.coroutines.delay
@Composable
@ -52,30 +51,20 @@ internal fun NFTSendSuccessContent(
label = "Animate success content",
modifier = modifier,
) {
Column {
Box(
modifier = Modifier
.weight(1f)
.background(TangemTheme.colors.background.tertiary),
) {
SuccessContent(
nftSendUM = nftSendUM,
nftDetailsBlockComponent = nftDetailsBlockComponent,
destinationBlockComponent = destinationBlockComponent,
modifier = Modifier.fillMaxHeight(),
)
Fade(
modifier = Modifier.align(Alignment.BottomCenter),
backgroundColor = TangemTheme.colors.background.tertiary,
)
}
NavigationButtonsBlockV2(
navigationUM = nftSendUM.navigationUM,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
Box(
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors.background.tertiary),
) {
SuccessContent(
nftSendUM = nftSendUM,
nftDetailsBlockComponent = nftDetailsBlockComponent,
destinationBlockComponent = destinationBlockComponent,
modifier = Modifier.fillMaxHeight(),
)
Fade(
modifier = Modifier.align(Alignment.BottomCenter),
backgroundColor = TangemTheme.colors.background.tertiary,
)
}
}

View file

@ -1,13 +1,11 @@
package com.tangem.features.send.sendnft.ui.state
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.api.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

@ -8,18 +8,22 @@ import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.ui.AmountBlockV2
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.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.amount.SendAmountBlockComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.subcomponents.amount.model.SendAmountModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
internal class SendAmountBlockComponent(
appComponentContext: AppComponentContext,
private val params: SendAmountComponentParams.AmountBlockParams,
val onResult: (AmountState) -> Unit,
val onClick: () -> Unit,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
internal class DefaultSendAmountBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendAmountComponentParams.AmountBlockParams,
@Assisted val onResult: (AmountState) -> Unit,
@Assisted val onClick: () -> Unit,
) : SendAmountBlockComponent, AppComponentContext by appComponentContext {
private val model: SendAmountModel = getOrCreateModel(params = params)
@ -29,7 +33,7 @@ internal class SendAmountBlockComponent(
}.launchIn(componentScope)
}
fun updateState(amountUM: AmountState) = model.updateState(amountUM)
override fun updateState(amountUM: AmountState) = model.updateState(amountUM)
@Composable
override fun Content(modifier: Modifier) {
@ -44,4 +48,14 @@ internal class SendAmountBlockComponent(
modifier = modifier,
)
}
@AssistedFactory
interface Factory : SendAmountBlockComponent.Factory {
override fun create(
context: AppComponentContext,
params: SendAmountComponentParams.AmountBlockParams,
onClick: () -> Unit,
onResult: (AmountState) -> Unit,
): DefaultSendAmountBlockComponent
}
}

View file

@ -0,0 +1,97 @@
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
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultSendAmountComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendAmountComponentParams.AmountParams,
) : SendAmountComponent, AppComponentContext by appComponentContext {
private val model: SendAmountModel = getOrCreateModel(params = params)
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()
val isSendWithSwapAvailable by model.isSendWithSwapAvailable.collectAsStateWithLifecycle()
SendAmountContent(
amountState = state,
clickIntents = model,
isSendWithSwapAvailable = isSendWithSwapAvailable,
modifier = modifier,
)
}
@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(
context: AppComponentContext,
params: SendAmountComponentParams.AmountParams,
): DefaultSendAmountComponent
}
}

View file

@ -1,46 +1,15 @@
package com.tangem.features.send.subcomponents.amount
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData
import kotlinx.coroutines.flow.Flow
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger
import kotlinx.coroutines.flow.MutableSharedFlow
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
/**
* Trigger for reducing amount from another component
*/
interface SendAmountReduceTrigger {
suspend fun triggerReduceBy(reduceBy: ReduceByData)
suspend fun triggerReduceTo(reduceTo: BigDecimal)
suspend fun triggerIgnoreReduce()
}
/**
* Trigger for reducing amount from another component
*/
interface SendAmountReduceListener {
val reduceToTriggerFlow: Flow<BigDecimal>
val reduceByTriggerFlow: Flow<ReduceByData>
val ignoreReduceTriggerFlow: Flow<Unit>
}
/**
* Trigger amount change from another component.
* Different from another triggers because it takes raw string instead of BigDecimal
*/
interface SendAmountUpdateTrigger {
suspend fun triggerUpdateAmount(amountValue: String, isEnterInFiatSelected: Boolean?)
}
/**
* Trigger amount change from another component.
* Different from another triggers because it takes raw string instead of BigDecimal
*/
interface SendAmountUpdateListener {
val updateAmountTriggerFlow: Flow<Pair<String, Boolean?>>
}
@Singleton
internal class DefaultSendAmountReduceTrigger @Inject constructor() :
SendAmountReduceTrigger,

View file

@ -1,44 +0,0 @@
package com.tangem.features.send.subcomponents.amount
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.amountScreen.models.AmountState
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.domain.wallets.models.GetUserWalletError
import com.tangem.features.send.subcomponents.amount.model.SendAmountModel
import com.tangem.features.send.subcomponents.amount.ui.SendAmountContent
internal class SendAmountComponent(
appComponentContext: AppComponentContext,
private val params: SendAmountComponentParams.AmountParams,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendAmountModel = getOrCreateModel(params = params)
fun updateState(amountUM: AmountState) = model.updateState(amountUM)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val isSendWithSwapAvailable by model.isSendWithSwapAvailable.collectAsStateWithLifecycle()
SendAmountContent(
amountState = state,
clickIntents = model,
isSendWithSwapAvailable = isSendWithSwapAvailable,
modifier = modifier,
)
}
interface ModelCallback : NavigationModelCallback {
fun onAmountResult(amountUM: AmountState, isResetPredefined: Boolean)
fun onConvertToAnotherToken(lastAmount: String, isEnterInFiatSelected: Boolean)
fun resetSendNavigation()
fun onError(error: GetUserWalletError)
}
}

View file

@ -1,60 +0,0 @@
package com.tangem.features.send.subcomponents.amount
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
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.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.common.CommonSendRoute
import kotlinx.coroutines.flow.StateFlow
internal sealed class SendAmountComponentParams {
abstract val state: AmountState
abstract val analyticsCategoryName: String
abstract val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource
abstract val userWalletId: UserWalletId
abstract val appCurrency: AppCurrency
abstract val predefinedValues: PredefinedValues
abstract val cryptoCurrency: CryptoCurrency
abstract val cryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>
abstract val isBalanceHidingFlow: StateFlow<Boolean>
abstract val accountFlow: StateFlow<Account?>
abstract val isAccountModeFlow: StateFlow<Boolean>
data class AmountParams(
override val state: AmountState,
override val analyticsCategoryName: String,
override val userWalletId: UserWalletId,
override val appCurrency: AppCurrency,
override val predefinedValues: PredefinedValues,
override val cryptoCurrency: CryptoCurrency,
override val cryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>,
override val isBalanceHidingFlow: StateFlow<Boolean>,
override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
override val accountFlow: StateFlow<Account?>,
override val isAccountModeFlow: StateFlow<Boolean>,
val callback: SendAmountComponent.ModelCallback,
val currentRoute: StateFlow<CommonSendRoute>,
) : SendAmountComponentParams()
data class AmountBlockParams(
override val state: AmountState,
override val analyticsCategoryName: String,
override val appCurrency: AppCurrency,
override val userWalletId: UserWalletId,
override val predefinedValues: PredefinedValues,
override val cryptoCurrency: CryptoCurrency,
override val cryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>,
override val isBalanceHidingFlow: StateFlow<Boolean>,
override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
override val accountFlow: StateFlow<Account?>,
override val isAccountModeFlow: StateFlow<Boolean>,
val userWallet: UserWallet,
val blockClickEnableFlow: StateFlow<Boolean>,
) : SendAmountComponentParams()
}

View file

@ -1,10 +1,9 @@
package com.tangem.features.send.subcomponents.amount.di
import com.tangem.features.send.api.subcomponents.amount.*
import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent
import com.tangem.features.send.subcomponents.amount.DefaultSendAmountComponent
import com.tangem.features.send.subcomponents.amount.DefaultSendAmountReduceTrigger
import com.tangem.features.send.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.subcomponents.amount.SendAmountUpdateTrigger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -15,6 +14,16 @@ import javax.inject.Singleton
@Module
internal interface SendAmountModule {
@Singleton
@Binds
fun provideSendAmountComponentFactory(impl: DefaultSendAmountComponent.Factory): SendAmountComponent.Factory
@Singleton
@Binds
fun provideSendAmountBlockComponentFactory(
impl: DefaultSendAmountBlockComponent.Factory,
): SendAmountBlockComponent.Factory
@Singleton
@Binds
fun provideSendAmountReduceTrigger(impl: DefaultSendAmountReduceTrigger): SendAmountReduceTrigger

View file

@ -9,13 +9,11 @@ import com.tangem.common.ui.amountScreen.converters.field.AmountFieldSetMaxAmoun
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.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
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.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -29,19 +27,19 @@ 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.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.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.properties.Delegates
@ -82,7 +80,6 @@ internal class SendAmountModel @Inject constructor(
private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull()
init {
configAmountNavigation()
initAppCurrency()
subscribeOnCryptoCurrencyStatusFlow()
subscribeOnAmountReduceByTriggerUpdates()
@ -102,6 +99,7 @@ internal class SendAmountModel @Inject constructor(
},
ifRight = { wallet ->
userWallet = wallet
setSendWithSwapAvailability()
},
)
}.launchIn(modelScope)
@ -271,13 +269,15 @@ internal class SendAmountModel @Inject constructor(
override fun onConvertToAnotherToken() {
val amountParams = params as? SendAmountComponentParams.AmountParams ?: return
if (amountParams.currentRoute.value.isEditMode) {
sendAmountAlertFactory.showResetSendingAlert {
params.callback.resetSendNavigation()
modelScope.launch {
if (amountParams.route.isEditMode) {
sendAmountAlertFactory.showResetSendingAlert {
params.callback.resetSendNavigation()
confirmConvertToToken()
}
} else {
confirmConvertToToken()
}
} else {
confirmConvertToToken()
}
}
@ -357,42 +357,6 @@ internal class SendAmountModel @Inject constructor(
)
}
private fun configAmountNavigation() {
val params = params as? SendAmountComponentParams.AmountParams ?: return
combine(
flow = uiState,
flow2 = params.currentRoute.filterIsInstance<CommonSendRoute.Amount>(),
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

@ -58,6 +58,7 @@ private fun SendConvertTokenButton(onConvertToAnother: () -> Unit) {
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
.testTag(SendScreenTestTags.SWAP_TO_ANOTHER_TOKEN_BUTTON)
.clickable(
indication = null,
interactionSource = null,
@ -67,8 +68,7 @@ private fun SendConvertTokenButton(onConvertToAnother: () -> Unit) {
Row(
modifier = Modifier
.padding(12.dp)
.align(Alignment.Center)
.testTag(SendScreenTestTags.SWAP_TO_ANOTHER_TOKEN_BUTTON),
.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {

View file

@ -39,12 +39,15 @@ internal class DefaultSendDestinationBlockComponent @AssistedInject constructor(
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val isClickEnabled by params.blockClickEnableFlow.collectAsStateWithLifecycle()
val isAddContactVisible by model.showAddContact.collectAsStateWithLifecycle()
DestinationBlock(
destinationUM = state,
isClickDisabled = !isClickEnabled,
isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink,
onClick = onClick,
showAddContact = isAddContactVisible,
onAddContactClick = model::onAddContactClick,
)
}

View file

@ -1,14 +1,33 @@
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
import com.arkivanov.decompose.router.slot.dismiss
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.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
@ -18,18 +37,101 @@ import dagger.assisted.AssistedInject
internal class DefaultSendDestinationComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendDestinationComponentParams.DestinationParams,
addressBookFeatureToggles: AddressBookFeatureToggles,
contactsBlockFactory: AddressBookContactsBlockComponent.Factory,
addressSelectorFactory: AddressSelectorComponent.Factory,
) : SendDestinationComponent, AppComponentContext by appComponentContext {
private val model: SendDestinationModel = getOrCreateModel(params = params)
private val contactsBlock: AddressBookContactsBlockComponent? by lazy {
if (addressBookFeatureToggles.isAddressBookEnabled) {
contactsBlockFactory.create(
context = child("send_contacts_block"),
params = AddressBookContactsBlockComponent.Params(
network = params.cryptoCurrency.network,
queryFlow = model.addressQuery,
onContactClick = model::onContactClick,
onSeeAllClick = model::onSeeAllContactsClick,
),
)
} else {
null
}
}
private val addressSelectorSlot = childSlot(
source = model.addressSelectorNavigation,
serializer = null,
key = "send_address_selector_slot",
handleBackButton = true,
childFactory = { contact, componentContext ->
addressSelectorFactory.create(
context = childByContext(componentContext),
params = AddressSelectorComponent.Params(
contact = contact,
onAddressSelected = model::applySelectedContact,
onDismiss = { model.addressSelectorNavigation.dismiss() },
),
)
},
)
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()
val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle()
val selector by addressSelectorSlot.subscribeAsState()
SendDestinationContent(state = state, clickIntents = model, isBalanceHidden = isBalanceHidden)
SendDestinationContent(
state = state,
clickIntents = model,
isBalanceHidden = isBalanceHidden,
contactsBlock = contactsBlock,
)
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

View file

@ -6,11 +6,12 @@ internal enum class EnterAddressSource {
RecentAddress,
InputField,
MyWallets,
Contact,
;
val isPasted: Boolean
get() = this != InputField
val isAutoNext: Boolean
get() = this == RecentAddress || this == MyWallets
get() = this == RecentAddress || this == MyWallets || this == Contact
}

View file

@ -3,18 +3,22 @@ package com.tangem.features.send.subcomponents.destination.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import arrow.core.left
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.common.routing.entity.AddressBookOpenMode
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
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
@ -34,25 +38,18 @@ 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.*
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
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.subcomponents.destination.SendDestinationAlertFactory
import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents
import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationAddressTransformer
import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationInitialStateTransformer
import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationMemoTransformer
import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationPredefinedStateTransformer
import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationRecentListTransformer
import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationValidationResultTransformer
import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationValidationStartedTransformer
import com.tangem.features.send.subcomponents.destination.model.transformers.*
import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -88,6 +85,11 @@ internal class SendDestinationModel @Inject constructor(
private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase,
private val sendDestinationAlertFactory: SendDestinationAlertFactory,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
private val addressBookSendAnalytics: AddressBookSendAnalytics,
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
private val addressBookFeatureToggles: AddressBookFeatureToggles,
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
contactSelectionListener: ContactSelectionListener,
) : Model(), SendDestinationClickIntents {
private val params: SendDestinationComponentParams = paramsContainer.require()
@ -98,6 +100,39 @@ internal class SendDestinationModel @Inject constructor(
private val cryptoCurrency = params.cryptoCurrency
private val userWalletId = params.userWalletId
private val contacts: StateFlow<List<Contact>> =
getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null)
.map { verified -> verified.map { it.contact } }
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
val addressSelectorNavigation = SlotNavigation<MatchedContact>()
val addressQuery: StateFlow<String> = uiState
.map { (it as? DestinationUM.Content)?.addressTextField?.value.orEmpty() }
.distinctUntilChanged()
.stateIn(modelScope, SharingStarted.Eagerly, "")
/**
* Whether to offer saving the recipient to the address book under the recipient block. Only for the success-screen
* block ([DestinationBlockParams.isAddContactAvailable]) and only when the recipient was NOT a contact and its
* `(address, network)` pair is not already saved in the current wallet's book.
*/
val showAddContact: StateFlow<Boolean> =
if ((params as? DestinationBlockParams)?.isAddContactAvailable == true) {
combine(uiState, contacts) { state, contactList ->
val address = (state as? DestinationUM.Content)?.addressTextField ?: return@combine false
if (address.contactName != null) return@combine false // sent via a contact
val networkId = cryptoCurrency.network.rawId
contactList.none { contact ->
contact.addresses.any {
it.networkId.value == networkId && it.address.equals(address.actualAddress, ignoreCase = true)
}
}
}.stateIn(modelScope, SharingStarted.Eagerly, false)
} else {
MutableStateFlow(false)
}
// In "Send with swap" flow, these are addresses in the destination network (not the actual sender addresses).
// Self-send validation must be skipped for them, so use only with params.isAllowSelfSend.
private val senderAddresses = MutableStateFlow<List<CryptoCurrencyAddress>>(emptyList())
@ -107,9 +142,62 @@ internal class SendDestinationModel @Inject constructor(
private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null)
init {
configDestinationNavigation()
syncAddressBooksIfNeeded()
subscribeOnQRScannerResult()
initialState()
resetContactOnEdit()
contactSelectionListener.resultFlow
.onEach(::applySelectedContact)
.launchIn(modelScope)
}
private fun resetContactOnEdit() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return
if (params.route.isEditMode) {
val content = uiState.value as? DestinationUM.Content ?: return
if (content.addressTextField.contactName != null) {
_uiState.update(SendDestinationContactTransformer(contact = null))
}
}
}
fun onContactClick(contact: MatchedContact) {
val singleEntry = contact.entries.singleOrNull()
if (singleEntry != null) {
applySelectedContact(contact.toSelectedContact(singleEntry))
} else {
addressSelectorNavigation.activate(contact)
}
}
fun onSeeAllContactsClick() {
router.push(
AppRoute.AddressBook(AddressBookOpenMode.ContactSelection(networkId = cryptoCurrency.network.rawId)),
)
}
/** Opens the contact editor pre-filled with the sent address/network to save the recipient (success screen). */
fun onAddContactClick() {
val address = (uiState.value as? DestinationUM.Content)?.addressTextField?.actualAddress ?: return
router.push(
AppRoute.AddressBook(
AddressBookOpenMode.WithContactCreation(address = address, networkId = cryptoCurrency.network.rawId),
),
)
}
fun applySelectedContact(contact: SelectedContact) {
addressSelectorNavigation.dismiss()
addressBookSendAnalytics.onAddressSubstitutedInSend(walletId = userWalletId, contactId = contact.contactId)
_uiState.update(SendDestinationAddressTransformer(address = contact.address, isPasted = true))
_uiState.update(SendDestinationContactTransformer(contactName = contact.name, contactIcon = contact.icon))
val isMemoSupported = (uiState.value as? DestinationUM.Content)?.memoTextField != null
val memo = contact.memo?.takeIf { isMemoSupported && it.isNotBlank() }
if (memo != null) {
_uiState.update(SendDestinationMemoTransformer(memo = memo, isPasted = true))
}
validate(address = contact.address, memo = memo, type = EnterAddressSource.Contact)
}
private fun initialState() {
@ -169,7 +257,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)
}
@ -186,6 +290,12 @@ internal class SendDestinationModel @Inject constructor(
}.launchIn(modelScope)
}
private fun syncAddressBooksIfNeeded() {
if (addressBookFeatureToggles.isAddressBookEnabled) {
modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() }
}
}
private fun subscribeOnQRScannerResult() {
listenToQrScanningUseCase(SourceType.SEND)
.getOrElse { emptyFlow() }
@ -371,6 +481,7 @@ internal class SendDestinationModel @Inject constructor(
isMemoRequired = isMemoRequired,
),
)
recognizeContact(type = type, isValidAddress = addressValidationResult.isRight(), address = resolvedAddress)
if (type != null) {
autoNextFromRecipient(
type = type,
@ -381,61 +492,26 @@ internal class SendDestinationModel @Inject constructor(
}.saveIn(validationJobHolder)
}
private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) {
if (type.isAutoNext && isValidAddress && isValidMemo) {
saveResult()
(params as? SendDestinationComponentParams.DestinationParams)?.callback?.onNextClick()
private fun recognizeContact(type: EnterAddressSource?, isValidAddress: Boolean, address: String) {
if (type == null || type == EnterAddressSource.Contact) return
val contact = if (isValidAddress) findContactByAddress(address) else null
_uiState.update(SendDestinationContactTransformer(contact))
}
private fun findContactByAddress(address: String): Contact? {
val networkId = cryptoCurrency.network.rawId
return contacts.value.firstOrNull { contact ->
contact.addresses.any { entry ->
entry.networkId.value == networkId && entry.address.equals(address, ignoreCase = true)
}
}
}
@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 fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) {
if (type.isAutoNext && isValidAddress && isValidMemo) {
saveResult()
(params as? SendDestinationComponentParams.DestinationParams)?.callback?.onNextClick(params.route)
}
}
private companion object {

View file

@ -0,0 +1,15 @@
package com.tangem.features.send.subcomponents.destination.model.converter
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.utils.converter.Converter
internal object ContactIconConverter : Converter<Contact, AccountIconUM.CryptoPortfolio> {
override fun convert(value: Contact): AccountIconUM.CryptoPortfolio = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == value.iconColor }
?: CryptoPortfolioIcon.Color.Azure,
)
}

View file

@ -0,0 +1,29 @@
package com.tangem.features.send.subcomponents.destination.model.transformers
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.Contact
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.subcomponents.destination.model.converter.ContactIconConverter
import com.tangem.utils.transformer.Transformer
internal class SendDestinationContactTransformer(
private val contactName: String?,
private val contactIcon: AccountIconUM.CryptoPortfolio?,
) : Transformer<DestinationUM> {
constructor(contact: Contact?) : this(
contactName = contact?.name?.value,
contactIcon = contact?.let(ContactIconConverter::convert),
)
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(
addressTextField = state.addressTextField.copy(
contactName = contactName,
contactIcon = contactIcon,
),
)
}
}

View file

@ -15,17 +15,21 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.account.AccountIcon
import com.tangem.core.ui.components.SecondaryButtonIconStart
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SendConfirmScreenTestTags
import com.tangem.features.send.impl.R
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationRecipientListUM
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.impl.R
import kotlinx.collections.immutable.toImmutableList
@Composable
@ -34,6 +38,8 @@ internal fun DestinationBlock(
isClickDisabled: Boolean,
isEditingDisabled: Boolean,
onClick: () -> Unit,
showAddContact: Boolean = false,
onAddContactClick: () -> Unit = {},
) {
if (destinationUM !is DestinationUM.Content) return
@ -50,6 +56,16 @@ internal fun DestinationBlock(
address = destinationUM.addressTextField,
memo = destinationUM.memoTextField,
)
if (showAddContact) {
SecondaryButtonIconStart(
text = stringResourceSafe(com.tangem.core.ui.R.string.address_book_add_contact),
iconResId = com.tangem.core.ui.R.drawable.ic_plus_24,
onClick = onAddContactClick,
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing12),
)
}
}
}
@ -68,6 +84,8 @@ private fun AddressWithMemoBlock(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24),
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
) {
val contactName = address.contactName
val contactIcon = address.contactIcon
Column(modifier = Modifier.weight(1f)) {
Text(
text = address.value,
@ -85,14 +103,23 @@ private fun AddressWithMemoBlock(
)
}
}
IdentIcon(
address = address.value,
modifier = Modifier
.size(TangemTheme.dimens.size36)
.clip(RoundedCornerShape(TangemTheme.dimens.radius18))
.background(TangemTheme.colors.background.tertiary)
.testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON),
)
if (contactName != null && contactIcon != null) {
AccountIcon(
name = stringReference(contactName),
icon = contactIcon,
size = AccountIconSize.ContactDefault,
modifier = Modifier.testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON),
)
} else {
IdentIcon(
address = address.value,
modifier = Modifier
.size(TangemTheme.dimens.size36)
.clip(RoundedCornerShape(TangemTheme.dimens.radius18))
.background(TangemTheme.colors.background.tertiary)
.testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON),
)
}
}
if (memo != null && memo.value.isNotBlank()) {

View file

@ -30,6 +30,7 @@ import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.SendAddressScreenTestTags
import com.tangem.core.ui.utils.GlobalMultipleClickPreventer
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationRecipientListUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
@ -47,6 +48,7 @@ internal fun SendDestinationContent(
state: DestinationUM,
clickIntents: SendDestinationClickIntents,
isBalanceHidden: Boolean,
contactsBlock: AddressBookContactsBlockComponent? = null,
) {
if (state !is DestinationUM.Content) return
val recipients = state.recent
@ -75,6 +77,15 @@ internal fun SendDestinationContent(
onMemoChange = clickIntents::onRecipientMemoValueChange,
)
}
if (contactsBlock != null && !state.isRecentHidden) {
item(key = "CONTACTS_BLOCK_KEY") {
contactsBlock.Content(
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
)
}
}
listHeaderItem(
titleRes = if (state.isAccountsMode == true) {
R.string.common_accounts

View file

@ -1,7 +1,7 @@
package com.tangem.features.send.subcomponents.fee.model.converters.custom
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList

View file

@ -11,7 +11,7 @@ import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance
import com.tangem.features.send.subcomponents.fee.model.converters.custom.CustomFeeConverter
import com.tangem.features.send.impl.R

View file

@ -2,7 +2,7 @@ package com.tangem.features.send.subcomponents.fee.model.converters.custom.ether
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.features.send.subcomponents.fee.model.converters.custom.CustomFeeConverter
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import kotlinx.collections.immutable.ImmutableList
/**

View file

@ -10,7 +10,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance
import com.tangem.features.send.impl.R
import kotlinx.collections.immutable.ImmutableList

View file

@ -11,7 +11,7 @@ import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance
import com.tangem.features.send.subcomponents.fee.model.converters.custom.setEmpty
import com.tangem.features.send.impl.R

View file

@ -11,7 +11,7 @@ import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance
import com.tangem.features.send.subcomponents.fee.model.converters.custom.setEmpty
import com.tangem.features.send.impl.R

View file

@ -11,7 +11,7 @@ import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.features.send.subcomponents.fee.model.converters.custom.CustomFeeConverter
import com.tangem.features.send.impl.R
import kotlinx.collections.immutable.ImmutableList

View file

@ -1,6 +1,6 @@
package com.tangem.features.send.subcomponents.notifications
import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData
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 kotlinx.coroutines.flow.MutableSharedFlow

View file

@ -5,8 +5,8 @@ import androidx.compose.ui.Modifier
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.send.api.SendNotificationsComponent
import com.tangem.features.send.api.SendNotificationsComponent.Params
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params
import com.tangem.features.send.subcomponents.notifications
import com.tangem.features.send.subcomponents.notifications.model.NotificationsModel
import dagger.assisted.Assisted

View file

@ -38,8 +38,8 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.api.SendNotificationsComponent
import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData
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.feeSelector.utils.FeeCalculationUtils.checkAndCalculateSubtractedAmount
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkFeeCoverage
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener

View file

@ -0,0 +1,59 @@
package com.tangem.features.send
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import java.math.BigDecimal
/**
* Builds a [TestingCoroutineDispatcherProvider] backed by a single [StandardTestDispatcher] wired to this scope's
* [TestScope.testScheduler], so `advanceUntilIdle()` drives all five dispatcher roles. Use in `Model`-layer tests
* instead of copying the wiring per file.
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal fun TestScope.testDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
/**
* Shared `Loaded` status fixture for send-impl tests. Only [currency], [fiatRate] and [balance] differ between
* call sites; the rest is incidental and never asserted.
*/
internal fun loadedStatus(
currency: CryptoCurrency,
fiatRate: BigDecimal = BigDecimal.ONE,
balance: BigDecimal = BigDecimal.ONE,
): CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Loaded(
amount = balance,
fiatAmount = fiatRate,
fiatRate = fiatRate,
priceChange = BigDecimal.ZERO,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = NetworkAddress.Single(
NetworkAddress.Address(value = "address", type = NetworkAddress.Address.Type.Primary),
),
sources = CryptoCurrencyStatus.Sources(),
),
)
/** Throwaway [Fee.Common] for tests that only need "some fee" of a non-special type. */
internal fun commonFee(blockchain: Blockchain = Blockchain.Ethereum): Fee.Common = Fee.Common(Amount(blockchain))

View file

@ -0,0 +1,175 @@
package com.tangem.features.send.feeselector.model
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.decompose.ui.UiMessageSender
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.feeSelector.utils.FeeCalculationUtils
import com.tangem.features.send.commonFee
import com.tangem.test.core.ProvideTestModels
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FeeSelectorAlertFactoryTest {
private val messageSender: UiMessageSender = mockk(relaxed = true)
private val factory = FeeSelectorAlertFactory(messageSender)
@BeforeEach
fun resetSender() {
clearMocks(messageSender)
}
private fun ethFee(value: String): Fee =
Fee.Common(Amount(currencySymbol = "ETH", value = BigDecimal(value), decimals = 18))
private fun content(selected: FeeItem) = FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
fees = TransactionFee.Single(normal = commonFee()),
feeItems = persistentListOf(selected),
selectedFeeItem = selected,
feeExtraInfo = mockk(),
feeFiatRateUM = null,
feeNonce = FeeNonce.None,
)
private fun choosable(normal: Fee, minimum: Fee, priority: Fee) =
TransactionFee.Choosable(normal = normal, minimum = minimum, priority = priority)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetFeeUpdatedAlert {
@ParameterizedTest
@ProvideTestModels
fun `GIVEN reloaded fee WHEN getFeeUpdatedAlert THEN resolves to warn proceed or nothing`(model: UpdatedModel) {
// Arrange
val proceed: () -> Unit = mockk(relaxed = true)
// Act
factory.getFeeUpdatedAlert(
model.newFee,
model.state,
proceedAction = proceed,
stopAction = mockk(relaxed = true),
)
// Assert
verify(exactly = if (model.outcome == Outcome.DIALOG) 1 else 0) { messageSender.send(any()) }
verify(exactly = if (model.outcome == Outcome.PROCEED) 1 else 0) { proceed() }
}
private fun provideTestModels() = listOf(
// Market -> normal, higher -> warn
UpdatedModel(
content(FeeItem.Market(ethFee("1"))),
choosable(ethFee("2"), ethFee("0"), ethFee("0")),
Outcome.DIALOG
),
// Market -> normal, not higher -> proceed
UpdatedModel(
content(FeeItem.Market(ethFee("2"))),
choosable(ethFee("1"), ethFee("0"), ethFee("0")),
Outcome.PROCEED
),
// Slow -> minimum
UpdatedModel(
content(FeeItem.Slow(ethFee("1"))),
choosable(ethFee("0"), ethFee("2"), ethFee("0")),
Outcome.DIALOG
),
// Fast -> priority
UpdatedModel(
content(FeeItem.Fast(ethFee("1"))),
choosable(ethFee("0"), ethFee("0"), ethFee("2")),
Outcome.DIALOG
),
// Single -> normal
UpdatedModel(
content(FeeItem.Market(ethFee("1"))),
TransactionFee.Single(ethFee("2")),
Outcome.DIALOG
),
// Suggested -> its own fee == old fee, never higher -> proceed
UpdatedModel(
content(FeeItem.Suggested(title = mockk(), fee = ethFee("5"))),
choosable(ethFee("9"), ethFee("9"), ethFee("9")),
Outcome.PROCEED,
),
// Custom selected -> early return, nothing happens
UpdatedModel(
content(FeeItem.Custom(fee = ethFee("1"), customValues = persistentListOf())),
choosable(ethFee("9"), ethFee("9"), ethFee("9")),
Outcome.NOTHING,
),
// non-content state -> early return, nothing happens
UpdatedModel(
FeeSelectorUM.Loading,
TransactionFee.Single(ethFee("2")),
Outcome.NOTHING
),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class CheckAndShowAlerts {
@BeforeEach
fun mockUtils() {
mockkObject(FeeCalculationUtils)
}
@AfterEach
fun unmockUtils() {
unmockkObject(FeeCalculationUtils)
}
@ParameterizedTest
@ProvideTestModels
fun `GIVEN fee validity WHEN checkAndShowAlerts THEN confirms only when no alert shown`(model: AlertsModel) {
// Arrange
every { FeeCalculationUtils.checkIfCustomFeeTooLow(any()) } returns model.tooLow
every { FeeCalculationUtils.checkIfCustomFeeTooHigh(any()) } returns (model.tooHigh to "5")
val onConfirm: () -> Unit = mockk(relaxed = true)
// Act
factory.checkAndShowAlerts(content(FeeItem.Market(ethFee("1"))), onConfirm)
// Assert
verify(exactly = model.expectedSends) { messageSender.send(any()) }
verify(exactly = if (model.expectConfirm) 1 else 0) { onConfirm() }
}
private fun provideTestModels() = listOf(
AlertsModel(tooLow = false, tooHigh = false, expectedSends = 0, expectConfirm = true),
AlertsModel(tooLow = true, tooHigh = false, expectedSends = 1, expectConfirm = false),
AlertsModel(tooLow = false, tooHigh = true, expectedSends = 1, expectConfirm = false),
)
}
enum class Outcome { DIALOG, PROCEED, NOTHING }
data class UpdatedModel(val state: FeeSelectorUM, val newFee: TransactionFee, val outcome: Outcome)
data class AlertsModel(
val tooLow: Boolean,
val tooHigh: Boolean,
val expectedSends: Int,
val expectConfirm: Boolean,
)
}

View file

@ -0,0 +1,340 @@
package com.tangem.features.send.feeselector.model
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
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.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration
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.FeeSelectorReloadListener
import com.tangem.features.send.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents
import com.tangem.features.send.loadedStatus
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.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
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.params.ParameterizedTest
import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
internal class FeeSelectorLogicTest {
private val testUserWalletId = UserWalletId("1234567890ABCDEF")
private val coinStatus: CryptoCurrencyStatus = loadedStatus(mockk<CryptoCurrency.Coin>(relaxed = true))
private val tokenStatus: CryptoCurrencyStatus = loadedStatus(mockk<CryptoCurrency.Token>(relaxed = true))
private val isFeeApproximateUseCase: IsFeeApproximateUseCase = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true)
private val feeSelectorReloadListener: FeeSelectorReloadListener = mockk(relaxed = true)
private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener = mockk(relaxed = true)
private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger = mockk(relaxed = true)
private val feeSelectorAlertFactory: FeeSelectorAlertFactory = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true)
private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
private val getAvailableFeeTokensUseCase: GetAvailableFeeTokensUseCase = mockk(relaxed = true)
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk(relaxed = true)
private val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee> = mockk()
private val onLoadFeeExtended: suspend (CryptoCurrencyStatus?) -> Either<GetFeeError, TransactionFeeExtended> =
mockk()
private val checkReloadTriggerFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
// PER_CLASS parameterized nested classes reuse one instance — reset analytics recorded calls between rows.
clearMocks(analyticsEventHandler, answers = false, recordedCalls = true, childMocks = false)
coEvery { onLoadFee() } returns GetFeeError.UnknownError.left()
coEvery { onLoadFeeExtended(any()) } returns GetFeeError.UnknownError.left()
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
every { feeSelectorReloadListener.reloadTriggerFlow } returns emptyFlow()
every { feeSelectorReloadListener.loadingStateTriggerFlow } returns emptyFlow()
every { feeSelectorCheckReloadListener.checkReloadTriggerFlow } returns checkReloadTriggerFlow
every { isGaslessFeeSupportedForNetwork(any()) } returns false
every { isFeeApproximateUseCase(any(), any()) } returns false
}
@Nested
inner class CallLoadFee {
@Test
fun `GIVEN gasless disabled WHEN load fee THEN use basic onLoadFee only`() =
runTest(UnconfinedTestDispatcher()) {
// Act — init triggers loadFee()
buildModel(gaslessEnabled = false)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { onLoadFee() }
coVerify(exactly = 0) { onLoadFeeExtended(any()) }
}
@Test
fun `GIVEN gasless not enough funds WHEN load fee THEN surface error without basic fallback`() =
runTest(UnconfinedTestDispatcher()) {
// Arrange
coEvery { onLoadFeeExtended(any()) } returns GetFeeError.GaslessError.NotEnoughFunds.left()
// Act
val sut = buildModel(gaslessEnabled = true)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { onLoadFeeExtended(any()) }
coVerify(exactly = 0) { onLoadFee() }
assertThat(sut.uiState.value).isInstanceOf(FeeSelectorUM.Error::class.java)
}
@Test
fun `GIVEN gasless generic error WHEN load fee THEN fallback to basic and show only speed option`() =
runTest(UnconfinedTestDispatcher()) {
// Arrange
coEvery { onLoadFeeExtended(any()) } returns GetFeeError.GaslessError.NetworkIsNotSupported.left()
coEvery { onLoadFee() } returns GetFeeError.UnknownError.left()
// Act
val sut = buildModel(gaslessEnabled = true)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { onLoadFeeExtended(any()) }
coVerify(exactly = 1) { onLoadFee() }
assertThat(sut.shouldShowOnlySpeedOption.value).isTrue()
}
@Test
fun `GIVEN gasless success WHEN load fee THEN use extended and clear speed-only option`() =
runTest(UnconfinedTestDispatcher()) {
// Arrange — populateExtendedFee then fails (token not found) but the dispatch decision is already made
val feeExtended = TransactionFeeExtended(
transactionFee = singleFee(),
feeTokenId = mockk(relaxed = true), // != feeCryptoCurrencyStatus.currency.id -> token lookup
)
coEvery { onLoadFeeExtended(any()) } returns feeExtended.right()
coEvery { singleAccountStatusListSupplier.getSyncOrNull(any<UserWalletId>()) } returns null
// Act
val sut = buildModel(gaslessEnabled = true)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { onLoadFeeExtended(any()) }
assertThat(sut.shouldShowOnlySpeedOption.value).isFalse()
}
}
@Nested
inner class CheckLoadFee {
@Test
fun `GIVEN fee reloads successfully WHEN check requested THEN show fee-updated alert`() =
runTest(UnconfinedTestDispatcher()) {
// Arrange
coEvery { onLoadFee() } returns singleFee().right()
buildModel(gaslessEnabled = false)
advanceUntilIdle()
// Act
checkReloadTriggerFlow.tryEmit(Unit)
advanceUntilIdle()
// Assert
verify(atLeast = 1) { feeSelectorAlertFactory.getFeeUpdatedAlert(any(), any(), any(), any()) }
}
@Test
fun `GIVEN fee reload fails WHEN check requested THEN report failure and show unreachable error`() =
runTest(UnconfinedTestDispatcher()) {
// Arrange
coEvery { onLoadFee() } returns GetFeeError.UnknownError.left()
buildModel(gaslessEnabled = false)
advanceUntilIdle()
// Act
checkReloadTriggerFlow.tryEmit(Unit)
advanceUntilIdle()
// Assert
coVerify(atLeast = 1) { feeSelectorCheckReloadTrigger.callbackCheckResult(false) }
verify(atLeast = 1) { feeSelectorAlertFactory.getFeeUnreachableErrorState(any()) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnFeeItemSelected {
@ParameterizedTest
@ProvideTestModels
fun `WHEN fee item selected THEN send custom-fee analytics only for custom`(model: FeeItemSelectedModel) =
runTest(UnconfinedTestDispatcher()) {
// Arrange
val sut = buildModel(gaslessEnabled = false)
advanceUntilIdle()
// Act
sut.onFeeItemSelected(model.feeItem)
// Assert
verify(exactly = model.expectedAnalyticsCalls) {
analyticsEventHandler.send(ofType<CommonSendFeeAnalyticEvents.CustomFeeButtonClicked>())
}
}
private fun provideTestModels() = listOf(
FeeItemSelectedModel(
feeItem = FeeItem.Custom(fee = realFee(), customValues = persistentListOf()),
expectedAnalyticsCalls = 1,
),
FeeItemSelectedModel(feeItem = FeeItem.Market(fee = realFee()), expectedAnalyticsCalls = 0),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnDoneClick {
@ParameterizedTest
@ProvideTestModels
fun `WHEN done THEN always send selected-fee and gas-price only for edited custom`(model: DoneClickModel) =
runTest(UnconfinedTestDispatcher()) {
// Arrange
val sut = buildModel(gaslessEnabled = false)
advanceUntilIdle()
sut.uiState.value = contentState(selected = model.selected, normalValue = model.normalValue)
// Act
sut.onDoneClick()
// Assert
verify(exactly = 1) { analyticsEventHandler.send(ofType<CommonSendFeeAnalyticEvents.SelectedFee>()) }
verify(exactly = model.expectedGasPriceCalls) { analyticsEventHandler.send(ofType<CommonSendFeeAnalyticEvents.GasPriceInserter>()) }
}
private fun provideTestModels() = listOf(
// not custom -> no gas-price
DoneClickModel(
selected = FeeItem.Market(realFee("0.001")),
normalValue = "0.001",
expectedGasPriceCalls = 0
),
// custom but unedited (== normal) -> no gas-price
DoneClickModel(
selected = FeeItem.Custom(realFee("0.001"), persistentListOf()),
normalValue = "0.001",
expectedGasPriceCalls = 0,
),
// custom edited (!= normal) -> gas-price
DoneClickModel(
selected = FeeItem.Custom(realFee("0.005"), persistentListOf()),
normalValue = "0.001",
expectedGasPriceCalls = 1,
),
)
}
// region fixtures
private fun TestScope.buildModel(gaslessEnabled: Boolean): FeeSelectorLogic {
val currencyStatus = if (gaslessEnabled) tokenStatus else coinStatus
every { isGaslessFeeSupportedForNetwork(any()) } returns gaslessEnabled
val params = FeeSelectorParams.FeeSelectorBlockParams(
state = FeeSelectorUM.Loading,
userWalletId = testUserWalletId,
onLoadFeeExtended = if (gaslessEnabled) onLoadFeeExtended else null,
onLoadFee = onLoadFee,
cryptoCurrencyStatus = currencyStatus,
feeCryptoCurrencyStatus = currencyStatus,
feeStateConfiguration = FeeStateConfiguration.None,
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet,
analyticsCategoryName = "test_fee",
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
)
return FeeSelectorLogic(
params = params,
modelScope = backgroundScope,
isFeeApproximateUseCase = isFeeApproximateUseCase,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
feeSelectorReloadListener = feeSelectorReloadListener,
feeSelectorCheckReloadListener = feeSelectorCheckReloadListener,
feeSelectorCheckReloadTrigger = feeSelectorCheckReloadTrigger,
feeSelectorAlertFactory = feeSelectorAlertFactory,
analyticsEventHandler = analyticsEventHandler,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
getUserWalletUseCase = getUserWalletUseCase,
getAvailableFeeTokensUseCase = getAvailableFeeTokensUseCase,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
)
}
private fun contentState(selected: FeeItem, normalValue: String): FeeSelectorUM.Content {
val extraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
feeCryptoCurrencyStatus = coinStatus,
)
return FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
fees = singleFee(normalValue),
feeItems = persistentListOf(selected),
selectedFeeItem = selected,
feeExtraInfo = extraInfo,
feeFiatRateUM = null,
feeNonce = FeeNonce.None,
)
}
private fun realFee(value: String = "0.001"): Fee = Fee.Common(
Amount(currencySymbol = "ETH", value = BigDecimal(value), decimals = 18),
)
private fun singleFee(value: String = "0.001"): TransactionFee = TransactionFee.Single(normal = realFee(value))
data class FeeItemSelectedModel(val feeItem: FeeItem, val expectedAnalyticsCalls: Int)
data class DoneClickModel(val selected: FeeItem, val normalValue: String, val expectedGasPriceCalls: Int)
// endregion
}

View file

@ -0,0 +1,189 @@
package com.tangem.features.send.feeselector.model.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.commonFee
import com.tangem.features.send.loadedStatus
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FeeItemConverterTest {
// Bitcoin status so the custom-fee field converter yields fields for a Bitcoin normalFee.
private val feeStatus = loadedStatus(
currency = MockCryptoCurrencyFactory().createCoin(Blockchain.Bitcoin),
fiatRate = BigDecimal("50000"),
)
private val bitcoinFee: Fee = Fee.Bitcoin(
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
satoshiPerByte = BigDecimal("10"),
txSize = BigDecimal("250"),
)
private fun converter(
config: FeeSelectorParams.FeeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.None,
normalFee: Fee = commonFee(),
shouldDisableCustomFee: Boolean = true,
) = FeeItemConverter(
feeStateConfiguration = config,
normalFee = normalFee,
feeSelectorIntents = mockk(relaxed = true),
appCurrency = AppCurrency.Default,
cryptoCurrencyStatus = feeStatus,
shouldDisableCustomFee = shouldDisableCustomFee,
)
private fun choosable() =
TransactionFee.Choosable(normal = commonFee(), minimum = commonFee(), priority = commonFee())
private fun single() = TransactionFee.Single(normal = commonFee())
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Items {
@ParameterizedTest
@ProvideTestModels
fun `GIVEN config and transaction fee WHEN convert THEN fee items match configuration`(model: ItemsModel) {
// Act (custom fee disabled -> the list is purely config driven)
val actual = converter(config = model.config)
.convert(FeeItemConverter.Input(transactionFee = model.transactionFee, customFee = null))
// Assert
assertThat(actual.map { it::class.java }).containsExactlyElementsIn(model.expectedTypes).inOrder()
}
private fun provideTestModels() = listOf(
ItemsModel(
none(),
choosable(),
listOf(FeeItem.Slow::class.java, FeeItem.Market::class.java, FeeItem.Fast::class.java)
),
ItemsModel(none(), single(), listOf(FeeItem.Market::class.java)),
ItemsModel(
suggestion(),
choosable(),
listOf(
FeeItem.Suggested::class.java,
FeeItem.Slow::class.java,
FeeItem.Market::class.java,
FeeItem.Fast::class.java
),
),
ItemsModel(
suggestion(),
single(),
listOf(FeeItem.Suggested::class.java, FeeItem.Market::class.java)
),
ItemsModel(
excludeLow(),
choosable(),
listOf(FeeItem.Market::class.java, FeeItem.Fast::class.java)
),
ItemsModel(
excludeLow(),
single(),
listOf(FeeItem.Market::class.java)
),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class FeeAssignment {
@Test
fun `GIVEN choosable fee WHEN convert THEN slow market fast map to minimum normal priority`() {
// Arrange (distinct fees to detect any mis-mapping)
val minimum = ethFee(value = "1")
val normal = ethFee(value = "2")
val priority = ethFee(value = "3")
val fees = TransactionFee.Choosable(normal = normal, minimum = minimum, priority = priority)
// Act
val actual = converter(config = none()).convert(FeeItemConverter.Input(fees, customFee = null))
// Assert
assertThat((actual[0] as FeeItem.Slow).fee).isEqualTo(minimum)
assertThat((actual[1] as FeeItem.Market).fee).isEqualTo(normal)
assertThat((actual[2] as FeeItem.Fast).fee).isEqualTo(priority)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class CustomFee {
@Test
fun `GIVEN custom enabled and supported fee WHEN convert THEN custom fee appended`() {
// Act
val actual = converter(normalFee = bitcoinFee, shouldDisableCustomFee = false)
.convert(FeeItemConverter.Input(TransactionFee.Single(bitcoinFee), customFee = null))
// Assert
assertThat(actual).hasSize(2) // Market + Custom
assertThat(actual.last()).isInstanceOf(FeeItem.Custom::class.java)
}
@Test
fun `GIVEN custom disabled WHEN convert THEN no custom fee`() {
// Act
val actual = converter(normalFee = bitcoinFee, shouldDisableCustomFee = true)
.convert(FeeItemConverter.Input(TransactionFee.Single(bitcoinFee), customFee = null))
// Assert
assertThat(actual).hasSize(1) // Market only
}
@Test
fun `GIVEN unsupported fee with no custom fields WHEN convert THEN no custom fee`() {
// Act (Fee.Common has no custom field converter -> constructCustomFee returns null)
val actual = converter(normalFee = commonFee(), shouldDisableCustomFee = false)
.convert(FeeItemConverter.Input(TransactionFee.Single(commonFee()), customFee = null))
// Assert
assertThat(actual).hasSize(1) // Market only
}
@Test
fun `GIVEN custom fee provided WHEN convert THEN provided custom reused`() {
// Arrange
val provided = FeeItem.Custom(fee = bitcoinFee, customValues = persistentListOf())
// Act
val actual = converter(normalFee = bitcoinFee, shouldDisableCustomFee = false)
.convert(FeeItemConverter.Input(TransactionFee.Single(bitcoinFee), customFee = provided))
// Assert
assertThat(actual.last()).isEqualTo(provided)
}
}
private fun none() = FeeSelectorParams.FeeStateConfiguration.None
private fun excludeLow() = FeeSelectorParams.FeeStateConfiguration.ExcludeLow
private fun suggestion() = FeeSelectorParams.FeeStateConfiguration.Suggestion(title = mockk(), fee = commonFee())
private fun ethFee(value: String) =
Fee.Common(Amount(currencySymbol = "ETH", value = BigDecimal(value), decimals = 18))
data class ItemsModel(
val config: FeeSelectorParams.FeeStateConfiguration,
val transactionFee: TransactionFee,
val expectedTypes: List<Class<out FeeItem>>,
)
}

View file

@ -0,0 +1,83 @@
package com.tangem.features.send.feeselector.model.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.loadedStatus
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
import java.math.BigInteger
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FeeSelectorCustomFieldConverterTest {
private val currencyFactory = MockCryptoCurrencyFactory()
// Bitcoin network so the Bitcoin converter passes its isUseBitcoinFeeConverter() check; other converters
// don't read the network, so a single status drives every dispatch branch.
private val feeStatus = loadedStatus(
currency = currencyFactory.createCoin(Blockchain.Bitcoin),
fiatRate = BigDecimal("50000"),
)
private val commonFee: Fee = Fee.Common(Amount(Blockchain.Ethereum))
private val bitcoinFee: Fee = Fee.Bitcoin(
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
satoshiPerByte = BigDecimal("10"),
txSize = BigDecimal("250"),
)
private val ethereumFee: Fee = Fee.Ethereum.EIP1559(
amount = Amount(Blockchain.Ethereum),
gasLimit = BigInteger.valueOf(21_000),
maxFeePerGas = BigInteger.valueOf(30_000_000_000),
priorityFee = BigInteger.valueOf(2_000_000_000),
)
private val kaspaFee: Fee = Fee.Kaspa(
amount = Amount(currencySymbol = "KAS", value = BigDecimal("0.0001"), decimals = 8),
mass = BigInteger.valueOf(2000),
feeRate = BigInteger.valueOf(5),
)
private fun converter(normalFee: Fee = commonFee) = FeeSelectorCustomFieldConverter(
feeSelectorIntents = mockk(relaxed = true),
appCurrency = AppCurrency.Default,
feeCryptoCurrencyStatus = feeStatus,
normalFee = normalFee,
)
@ParameterizedTest
@ProvideTestModels
fun `GIVEN fee type WHEN convert THEN routed to matching custom fee converter`(model: DispatchModel) {
// Act
val actual = converter().convert(model.fee)
// Assert (each converter emits a distinct number of fields - a fingerprint of correct routing)
assertThat(actual).hasSize(model.expectedFieldCount)
}
private fun provideTestModels() = listOf(
DispatchModel(fee = bitcoinFee, expectedFieldCount = 2), // amount + satoshi/byte
DispatchModel(fee = ethereumFee, expectedFieldCount = 4), // amount + maxFee + priority + gasLimit
DispatchModel(fee = kaspaFee, expectedFieldCount = 1), // amount
DispatchModel(fee = commonFee, expectedFieldCount = 0), // unsupported -> empty
)
@Test
fun `GIVEN empty custom values WHEN convertBack THEN returns normal fee unchanged`() {
// Act
val actual = converter(normalFee = commonFee).convertBack(persistentListOf())
// Assert
assertThat(actual).isSameInstanceAs(commonFee)
}
data class DispatchModel(val fee: Fee, val expectedFieldCount: Int)
}

View file

@ -0,0 +1,111 @@
package com.tangem.features.send.feeselector.model.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.appcurrency.model.AppCurrency
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.loadedStatus
import com.tangem.features.send.subcomponents.fee.model.converters.custom.kaspa.KaspaCustomFeeConverter
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.collections.immutable.toImmutableList
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
import java.math.BigInteger
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FeeSelectorCustomValueChangedTransformerTest {
private val currencyFactory = MockCryptoCurrencyFactory()
private val feeStatus = loadedStatus(
currency = currencyFactory.createCoin(Blockchain.Kaspa),
fiatRate = BigDecimal("0.1"),
)
private val kaspaFee = Fee.Kaspa(
amount = Amount(currencySymbol = "KAS", value = BigDecimal("0.0001"), decimals = 8),
mass = BigInteger.valueOf(2000),
feeRate = BigInteger.valueOf(5),
)
private val customItem = FeeItem.Custom(
fee = kaspaFee,
customValues = KaspaCustomFeeConverter(
onCustomFeeValueChange = { _, _ -> },
appCurrency = AppCurrency.Default,
feeCryptoCurrencyStatus = feeStatus,
).convert(kaspaFee),
)
private fun transformer(index: Int, value: String) = FeeSelectorCustomValueChangedTransformer(
index = index,
value = value,
intents = mockk(relaxed = true),
appCurrency = AppCurrency.Default,
feeCryptoCurrencyStatus = feeStatus,
)
private fun content(feeItems: List<FeeItem>, selected: FeeItem) = FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
fees = TransactionFee.Single(normal = kaspaFee),
feeItems = feeItems.toImmutableList(),
selectedFeeItem = selected,
feeExtraInfo = mockk(),
feeFiatRateUM = null,
feeNonce = FeeNonce.None,
)
@Test
fun `GIVEN custom fee and non-zero value WHEN transform THEN custom updated selected and button enabled`() {
// Arrange
val state = content(feeItems = listOf(customItem), selected = customItem)
// Act (index 0 = amount field of the Kaspa custom fee)
val result = transformer(index = 0, value = "0.0002").transform(state) as FeeSelectorUM.Content
// Assert
assertThat(result.isPrimaryButtonEnabled).isTrue()
assertThat(result.selectedFeeItem).isInstanceOf(FeeItem.Custom::class.java)
val updatedCustom = result.feeItems.filterIsInstance<FeeItem.Custom>().first()
assertThat(updatedCustom.customValues.first().value).isEqualTo("0.0002")
assertThat(result.selectedFeeItem).isEqualTo(updatedCustom)
}
@Test
fun `GIVEN custom fee edited to zero WHEN transform THEN button disabled`() {
// Arrange
val state = content(feeItems = listOf(customItem), selected = customItem)
// Act
val result = transformer(index = 0, value = "0").transform(state) as FeeSelectorUM.Content
// Assert
assertThat(result.isPrimaryButtonEnabled).isFalse()
}
@ParameterizedTest
@ProvideTestModels
fun `GIVEN non-applicable state WHEN transform THEN returned unchanged`(model: UnchangedModel) {
// Act
val result = transformer(index = 0, value = "0.0002").transform(model.state)
// Assert
assertThat(result).isSameInstanceAs(model.state)
}
private fun provideTestModels() = listOf(
UnchangedModel(state = FeeSelectorUM.Loading), // not a content state
UnchangedModel(state = content(feeItems = listOf(FeeItem.Market(kaspaFee)), selected = FeeItem.Market(kaspaFee))),
)
data class UnchangedModel(val state: FeeSelectorUM)
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.send.feeselector.model.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.transaction.error.GetFeeError
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.commonFee
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FeeSelectorErrorTransformerTest {
private val fee = commonFee()
private fun content() = FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
fees = TransactionFee.Single(normal = fee),
feeItems = persistentListOf(FeeItem.Market(fee)),
selectedFeeItem = FeeItem.Market(fee),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = mockk(),
),
feeFiatRateUM = null,
feeNonce = FeeNonce.None,
)
@Test
fun `GIVEN content state and not-enough-funds error WHEN transform THEN stays content with flag and disabled button`() {
// Act
val result = FeeSelectorErrorTransformer(GetFeeError.GaslessError.NotEnoughFunds)
.transform(content()) as FeeSelectorUM.Content
// Assert
assertThat(result.isPrimaryButtonEnabled).isFalse()
assertThat(result.feeExtraInfo.isNotEnoughFunds).isTrue()
}
@ParameterizedTest
@ProvideTestModels
fun `GIVEN other state or error WHEN transform THEN transitions to error`(model: ErrorModel) {
// Act
val result = FeeSelectorErrorTransformer(model.error).transform(model.state)
// Assert
assertThat(result).isEqualTo(FeeSelectorUM.Error(error = model.error))
}
private fun provideTestModels() = listOf(
// content but a different error -> the special branch needs NotEnoughFunds specifically
ErrorModel(state = content(), error = GetFeeError.UnknownError),
// not-enough-funds but not a content state -> the special branch needs a Content state
ErrorModel(state = FeeSelectorUM.Loading, error = GetFeeError.GaslessError.NotEnoughFunds),
)
data class ErrorModel(val state: FeeSelectorUM, val error: GetFeeError)
}

View file

@ -0,0 +1,186 @@
package com.tangem.features.send.feeselector.model.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
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.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.feeselector.model.FeeSelectorLogic
import com.tangem.features.send.loadedStatus
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
import java.math.BigInteger
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FeeSelectorLoadedTransformerTest {
private val currencyFactory = MockCryptoCurrencyFactory()
private val coin: CryptoCurrency = currencyFactory.ethereum
private val commonFee: Fee = Fee.Common(Amount(Blockchain.Ethereum))
private val ethereumFee: Fee = Fee.Ethereum.Legacy(
amount = Amount(Blockchain.Ethereum),
gasLimit = BigInteger.valueOf(21_000),
gasPrice = BigInteger.valueOf(1_000_000_000),
)
private fun status(currency: CryptoCurrency = coin): CryptoCurrencyStatus =
loadedStatus(currency = currency, fiatRate = BigDecimal("2000"))
private fun basic(normal: Fee): FeeSelectorLogic.LoadedFeeResult =
FeeSelectorLogic.LoadedFeeResult.Basic(TransactionFee.Choosable(normal = normal, minimum = normal, priority = normal))
private fun transformer(
fees: FeeSelectorLogic.LoadedFeeResult,
feeStateConfiguration: FeeSelectorParams.FeeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.None,
) = FeeSelectorLoadedTransformer(
cryptoCurrencyStatus = status(),
feeCryptoCurrencyStatus = status(),
appCurrency = AppCurrency.Default,
fees = fees,
feeStateConfiguration = feeStateConfiguration,
isFeeApproximate = false,
feeSelectorIntents = mockk(relaxed = true),
shouldDisableCustomFee = true,
)
private fun prevContent(selected: FeeItem, feeNonce: FeeNonce = FeeNonce.None) = FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
fees = TransactionFee.Single(normal = commonFee),
feeItems = persistentListOf(selected),
selectedFeeItem = selected,
feeExtraInfo = mockk(),
feeFiatRateUM = null,
feeNonce = feeNonce,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class SelectedFee {
@ParameterizedTest
@ProvideTestModels
fun `GIVEN previous state WHEN transform THEN selected fee item resolved`(model: SelectedModel) {
// Act
val result = transformer(basic(commonFee)).transform(model.prevState) as FeeSelectorUM.Content
// Assert
assertThat(result.selectedFeeItem).isInstanceOf(model.expected)
}
private fun provideTestModels() = listOf(
// no prior selection -> defaults to market (no suggested in this config)
SelectedModel(FeeSelectorUM.Loading, FeeItem.Market::class.java),
// prior loading selection -> market
SelectedModel(prevContent(FeeItem.Loading), FeeItem.Market::class.java),
// prior concrete selection -> same class preserved
SelectedModel(prevContent(FeeItem.Fast(commonFee)), FeeItem.Fast::class.java),
// prior class no longer present -> falls back to loading
SelectedModel(prevContent(FeeItem.Suggested(title = mockk(), fee = commonFee)), FeeItem.Loading::class.java),
)
@Test
fun `GIVEN selection falls back to loading WHEN transform THEN primary button disabled`() {
// Act
val result = transformer(basic(commonFee))
.transform(prevContent(FeeItem.Suggested(title = mockk(), fee = commonFee))) as FeeSelectorUM.Content
// Assert
assertThat(result.selectedFeeItem).isEqualTo(FeeItem.Loading)
assertThat(result.isPrimaryButtonEnabled).isFalse()
}
@Test
fun `GIVEN resolved fee item WHEN transform THEN primary button enabled`() {
// Act
val result = transformer(basic(commonFee)).transform(FeeSelectorUM.Loading) as FeeSelectorUM.Content
// Assert
assertThat(result.isPrimaryButtonEnabled).isTrue()
}
@Test
fun `GIVEN no prior selection and suggested available WHEN transform THEN suggested preselected`() {
// Arrange (Suggestion config makes the converter emit a Suggested item)
val config = FeeSelectorParams.FeeStateConfiguration.Suggestion(title = mockk(), fee = commonFee)
// Act
val result = transformer(basic(commonFee), feeStateConfiguration = config)
.transform(FeeSelectorUM.Loading) as FeeSelectorUM.Content
// Assert
assertThat(result.selectedFeeItem).isInstanceOf(FeeItem.Suggested::class.java)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Nonce {
@ParameterizedTest
@ProvideTestModels
fun `GIVEN normal fee type WHEN transform THEN nonce field present only for ethereum`(model: NonceTypeModel) {
// Act
val result = transformer(basic(model.normal)).transform(FeeSelectorUM.Loading) as FeeSelectorUM.Content
// Assert
assertThat(result.feeNonce).isInstanceOf(model.expected)
}
private fun provideTestModels() = listOf(
NonceTypeModel(ethereumFee, FeeNonce.Nonce::class.java),
NonceTypeModel(commonFee, FeeNonce.None::class.java),
)
@Test
fun `GIVEN ethereum fee and previous nonce WHEN transform THEN previous nonce preserved`() {
// Arrange
val prev = prevContent(
selected = FeeItem.Market(commonFee),
feeNonce = FeeNonce.Nonce(nonce = BigInteger.valueOf(7), onNonceChange = {}),
)
// Act
val result = transformer(basic(ethereumFee)).transform(prev) as FeeSelectorUM.Content
// Assert
assertThat((result.feeNonce as FeeNonce.Nonce).nonce).isEqualTo(BigInteger.valueOf(7))
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ExtraInfo {
@Test
fun `GIVEN basic fee result WHEN transform THEN extra info reflects basic non-tron status`() {
// Act
val result = transformer(basic(commonFee)).transform(FeeSelectorUM.Loading) as FeeSelectorUM.Content
// Assert
val info = result.feeExtraInfo
assertThat(info.availableFeeCurrencies).isNull() // Extended-only
assertThat(info.transactionFeeExtended).isNull() // Extended-only
assertThat(info.isTronToken).isFalse()
assertThat(info.isFeeConvertibleToFiat).isEqualTo(coin.network.hasFiatFeeRate)
assertThat(result.feeFiatRateUM).isNotNull()
}
}
data class SelectedModel(val prevState: FeeSelectorUM, val expected: Class<out FeeItem>)
data class NonceTypeModel(val normal: Fee, val expected: Class<out FeeNonce>)
}

View file

@ -0,0 +1,81 @@
package com.tangem.features.send.feeselector.model.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.TransactionFee
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.commonFee
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigInteger
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FeeSelectorNonceChangeTransformerTest {
private val fee = commonFee()
private fun content(feeNonce: FeeNonce) = FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
fees = TransactionFee.Single(normal = fee),
feeItems = persistentListOf(FeeItem.Market(fee)),
selectedFeeItem = FeeItem.Market(fee),
feeExtraInfo = mockk<FeeExtraInfo>(),
feeFiatRateUM = null,
feeNonce = feeNonce,
)
private fun nonceState(nonce: BigInteger?) = content(FeeNonce.Nonce(nonce = nonce, onNonceChange = {}))
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Update {
@ParameterizedTest
@ProvideTestModels
fun `GIVEN nonce field WHEN transform THEN nonce updated`(model: UpdateModel) {
// Arrange
val state = nonceState(nonce = BigInteger.ONE)
// Act
val result = FeeSelectorNonceChangeTransformer(model.value).transform(state) as FeeSelectorUM.Content
// Assert
assertThat((result.feeNonce as FeeNonce.Nonce).nonce).isEqualTo(model.expectedNonce)
}
private fun provideTestModels() = listOf(
UpdateModel(value = "42", expectedNonce = BigInteger.valueOf(42)), // valid number
UpdateModel(value = "", expectedNonce = null), // empty -> cleared
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Unchanged {
@ParameterizedTest
@ProvideTestModels
fun `GIVEN non-applicable input WHEN transform THEN state returned unchanged`(model: UnchangedModel) {
// Act
val result = FeeSelectorNonceChangeTransformer(model.value).transform(model.state)
// Assert
assertThat(result).isSameInstanceAs(model.state)
}
private fun provideTestModels() = listOf(
UnchangedModel(value = "abc", state = nonceState(nonce = BigInteger.ONE)), // non-numeric
UnchangedModel(value = "42", state = content(FeeNonce.None)), // no editable nonce
UnchangedModel(value = "42", state = FeeSelectorUM.Loading), // not a content state
)
}
data class UpdateModel(val value: String, val expectedNonce: BigInteger?)
data class UnchangedModel(val value: String, val state: FeeSelectorUM)
}

View file

@ -0,0 +1,65 @@
package com.tangem.features.send.feeselector.model.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.extensions.TextReference
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.commonFee
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.collections.immutable.toImmutableList
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FeeSelectorRemoveSuggestedTransformerTest {
private val fee = commonFee()
private val market = FeeItem.Market(fee)
private val fast = FeeItem.Fast(fee)
private val suggested = FeeItem.Suggested(title = TextReference.EMPTY, fee = fee)
@ParameterizedTest
@ProvideTestModels
fun `GIVEN suggested present WHEN transform THEN suggested removed and selection resolved`(model: SelectionModel) {
// Arrange
val state = content(feeItems = listOf(suggested, market, fast), selected = model.selected)
// Act
val result = FeeSelectorRemoveSuggestedTransformer.transform(state) as FeeSelectorUM.Content
// Assert
assertThat(result.feeItems).containsExactly(market, fast).inOrder()
assertThat(result.selectedFeeItem).isEqualTo(model.expectedSelected)
}
private fun provideTestModels() = listOf(
SelectionModel(selected = suggested, expectedSelected = market),
SelectionModel(selected = fast, expectedSelected = fast),
)
@Test
fun `GIVEN non-content state WHEN transform THEN returned unchanged`() {
// Act
val result = FeeSelectorRemoveSuggestedTransformer.transform(FeeSelectorUM.Loading)
// Assert
assertThat(result).isEqualTo(FeeSelectorUM.Loading)
}
private fun content(feeItems: List<FeeItem>, selected: FeeItem) = FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
fees = TransactionFee.Single(normal = fee),
feeItems = feeItems.toImmutableList(),
selectedFeeItem = selected,
feeExtraInfo = mockk<FeeExtraInfo>(),
feeFiatRateUM = null,
feeNonce = FeeNonce.None,
)
data class SelectionModel(val selected: FeeItem, val expectedSelected: FeeItem)
}

View file

@ -0,0 +1,282 @@
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
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
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.model.SendModel
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
import kotlinx.coroutines.test.TestScope
import org.junit.jupiter.api.BeforeEach
@OptIn(ExperimentalCoroutinesApi::class)
internal abstract class SendModelTestBase {
protected val testUserWalletId = UserWalletId("1234567890ABCDEF")
protected val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true)
protected val testUserWallet: UserWallet = mockk(relaxed = true)
protected val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) {
io.mockk.every { currency } returns testCryptoCurrency
}
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 getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true)
protected val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true)
protected val parseQrCodeUseCase: ParseQrCodeUseCase = mockk(relaxed = true)
protected val sendConfirmAlertFactory: SendConfirmAlertFactory = mockk(relaxed = true)
protected val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk(relaxed = true)
protected val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk(relaxed = true)
protected val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxed = true)
protected val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk(relaxed = true)
protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk(relaxed = true)
protected val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
protected val getFeeForGaslessUseCase: GetFeeForGaslessUseCase = mockk(relaxed = true)
protected val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
protected val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk(relaxed = true)
protected val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk(relaxed = true)
protected val sendAmountUpdateTrigger: SendAmountUpdateTrigger = mockk(relaxed = true)
protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
protected val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true)
// SendConfirmModel-specific dependencies
protected val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase = mockk(relaxed = true)
protected val neverShowTapHelpUseCase: NeverShowTapHelpUseCase = mockk(relaxed = true)
protected val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase = mockk(relaxed = true)
protected val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk(relaxed = true)
protected val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener = mockk(relaxed = true)
protected val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger = mockk(relaxed = true)
protected val notificationsUpdateTrigger: SendNotificationsUpdateTrigger = mockk(relaxed = true)
protected val notificationsUpdateListener: SendNotificationsUpdateListener = mockk(relaxed = true)
protected val urlOpener: UrlOpener = mockk(relaxed = true)
protected val shareManager: ShareManager = mockk(relaxed = true)
protected val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true)
protected val sendAmountReduceTrigger: SendAmountReduceTrigger = mockk(relaxed = true)
protected val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase = mockk(relaxed = true)
protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true)
protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true)
protected val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase = mockk(relaxed = true)
protected val sendFeatureToggles: SendFeatureToggles = mockk(relaxed = true)
protected val sendAnalyticHelper: SendAnalyticHelper = mockk(relaxed = true)
protected val sendBalanceUpdaterFactory: SendBalanceUpdater.Factory = mockk(relaxed = true)
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
// Reset recorded calls on use-cases asserted via coVerify(exactly=N). PER_CLASS parameterized
// tests (e.g. SendConfirmModelTest) reuse one instance, so calls would otherwise accumulate
// across rows. answers=false keeps the happy-path stubs re-applied below.
clearMocks(
createTransferTransactionUseCase,
sendTransactionUseCase,
createAndSendGaslessTransactionUseCase,
feeSelectorCheckReloadTrigger,
answers = false,
recordedCalls = true,
childMocks = false,
)
// --- SendModel init-path happy stubs ---
every { getUserWalletUseCase(testUserWalletId) } returns testUserWallet.right()
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right())
every { listenToQrScanningUseCase(SourceType.SEND) } returns emptyFlow<String>().right()
every { getBalanceHidingSettingsUseCase() } returns emptyFlow()
every { getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) } returns emptyFlow()
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns testCryptoCurrencyStatus.right()
// no-fee overload (disambiguated by memo: String at position 2); 6 matchers cover defaulted nonce
coEvery {
createTransferTransactionUseCase(any(), any<String>(), any(), any(), any(), any())
} returns mockk<TransactionData.Uncompiled>(relaxed = true).right()
// with-fee overload (disambiguated by Fee at position 2); 7 matchers cover defaulted nonce
coEvery {
createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any())
} returns mockk<TransactionData.Uncompiled>(relaxed = true).right()
coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right()
coEvery { createAndSendGaslessTransactionUseCase(any(), any(), any()) } returns "txHash".right()
every { getExplorerTransactionUrlUseCase(any(), any()) } returns "https://explorer/tx".right()
// --- SendConfirmModel init-path happy stubs ---
coEvery { isSendTapHelpEnabledUseCase.invokeSync() } returns false.right()
every { isSendTapHelpEnabledUseCase() } returns emptyFlow<Boolean>().right()
coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right()
every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns emptyFlow()
every { notificationsUpdateListener.hasErrorFlow } returns emptyFlow()
}
protected fun createSendModel(
testScope: TestScope,
paramsContainer: ParamsContainer = MutableParamsContainer(defaultSendParams()),
): SendModel {
return SendModel(
paramsContainer = paramsContainer,
dispatchers = testScope.testDispatcherProvider(),
router = router,
getUserWalletUseCase = getUserWalletUseCase,
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
listenToQrScanningUseCase = listenToQrScanningUseCase,
parseQrCodeUseCase = parseQrCodeUseCase,
sendConfirmAlertFactory = sendConfirmAlertFactory,
saveBlockchainErrorUseCase = saveBlockchainErrorUseCase,
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase,
createTransferTransactionUseCase = createTransferTransactionUseCase,
getFeeUseCase = getFeeUseCase,
getFeeForGaslessUseCase = getFeeForGaslessUseCase,
getFeeForTokenUseCase = getFeeForTokenUseCase,
getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
sendAmountUpdateTrigger = sendAmountUpdateTrigger,
analyticsEventHandler = analyticsEventHandler,
)
}
protected fun createSendConfirmModel(
testScope: TestScope,
paramsContainer: ParamsContainer = MutableParamsContainer(defaultSendConfirmParams()),
): SendConfirmModel {
return SendConfirmModel(
paramsContainer = paramsContainer,
dispatchers = testScope.testDispatcherProvider(),
analyticsEventHandler = analyticsEventHandler,
appRouter = appRouter,
router = router,
isSendTapHelpEnabledUseCase = isSendTapHelpEnabledUseCase,
neverShowTapHelpUseCase = neverShowTapHelpUseCase,
createTransferTransactionUseCase = createTransferTransactionUseCase,
sendTransactionUseCase = sendTransactionUseCase,
saveBlockchainErrorUseCase = saveBlockchainErrorUseCase,
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase,
isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase,
feeSelectorCheckReloadListener = feeSelectorCheckReloadListener,
feeSelectorCheckReloadTrigger = feeSelectorCheckReloadTrigger,
notificationsUpdateTrigger = notificationsUpdateTrigger,
notificationsUpdateListener = notificationsUpdateListener,
alertFactory = sendConfirmAlertFactory,
sendAnalyticHelper = sendAnalyticHelper,
urlOpener = urlOpener,
shareManager = shareManager,
feeSelectorReloadTrigger = feeSelectorReloadTrigger,
sendAmountReduceTrigger = sendAmountReduceTrigger,
getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase,
manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase,
currenciesRepository = currenciesRepository,
createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase,
isHighNetworkFeeUseCase = isHighNetworkFeeUseCase,
sendFeatureToggles = sendFeatureToggles,
sendBalanceUpdaterFactory = sendBalanceUpdaterFactory,
)
}
protected open fun defaultSendParams(): SendComponent.Params = SendComponent.Params(
userWalletId = testUserWalletId,
currency = testCryptoCurrency,
amount = null,
destinationAddress = null,
tag = null,
transactionId = null,
entryType = SendComponent.EntryType.Manual,
callback = mockk(relaxed = true),
)
protected fun defaultSendConfirmParams(
state: SendUM = SendUM(
amountUM = AmountState.Empty,
destinationUM = DestinationUM.Empty(),
feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty,
confirmData = null,
),
cryptoCurrencyStatus: CryptoCurrencyStatus = testCryptoCurrencyStatus,
feeCryptoCurrencyStatus: CryptoCurrencyStatus = testCryptoCurrencyStatus,
): SendConfirmComponent.Params = SendConfirmComponent.Params(
state = state,
analyticsCategoryName = "test_send",
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
userWallet = testUserWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
cryptoCurrencyStatusFlow = kotlinx.coroutines.flow.MutableStateFlow(cryptoCurrencyStatus),
feeCryptoCurrencyStatusFlow = kotlinx.coroutines.flow.MutableStateFlow(feeCryptoCurrencyStatus),
accountFlow = kotlinx.coroutines.flow.MutableStateFlow(null),
isAccountModeFlow = kotlinx.coroutines.flow.MutableStateFlow(false),
appCurrency = AppCurrency.Default,
callback = mockk(relaxed = true),
isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false),
predefinedValues = PredefinedValues.Empty,
onLoadFee = { Either.Right(mockk(relaxed = true)) },
onLoadFeeExtended = { Either.Right(mockk(relaxed = true)) },
onSendTransaction = {},
)
}

View file

@ -0,0 +1,317 @@
package com.tangem.features.send.send.confirm.model
import android.os.SystemClock
import arrow.core.left
import arrow.core.right
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.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.common.ui.state.ConfirmUM
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.*
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.*
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
internal class SendConfirmModelTest : SendModelTestBase() {
@BeforeEach
fun mockSystemClock() {
// SystemClock.elapsedRealtime() is read in init/subscription paths; default to a fresh timer.
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns 0L
}
@AfterEach
fun tearDown() {
unmockkStatic(SystemClock::class)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnSendClick {
@ParameterizedTest
@ProvideTestModels
fun `WHEN onSendClick THEN send fresh fee else trigger check reload`(model: OnSendClickModel) = runTest {
// Arrange
every { SystemClock.elapsedRealtime() } returns model.elapsedRealtime
val sut = createSendConfirmModel(this, confirmParams(normalFeeState()))
advanceUntilIdle()
// Act
sut.onSendClick()
advanceUntilIdle()
// Assert
if (model.expectedSendInitiated) {
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 = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
}
}
private fun provideTestModels() = listOf(
// diff = elapsedRealtime - sendIdleTimer(0); < 10s = fresh -> verify & send
OnSendClickModel(elapsedRealtime = 0L, expectedSendInitiated = true),
OnSendClickModel(elapsedRealtime = 20_000L, expectedSendInitiated = false),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class CheckFeeResult {
@ParameterizedTest
@ProvideTestModels
fun `WHEN check reload result emitted THEN send transaction only on success`(model: CheckFeeResultModel) =
runTest {
// Arrange
val resultFlow = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow
createSendConfirmModel(this, confirmParams(normalFeeState()))
advanceUntilIdle()
// Act
resultFlow.tryEmit(model.checkResult)
advanceUntilIdle()
// Assert
if (model.expectedSendInitiated) {
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()
)
}
}
}
private fun provideTestModels() = listOf(
CheckFeeResultModel(checkResult = true, expectedSendInitiated = true),
CheckFeeResultModel(checkResult = false, expectedSendInitiated = false),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class SendTransactionDispatch {
@ParameterizedTest
@ProvideTestModels
fun `WHEN send THEN use gasless use case only for token-currency fee`(model: DispatchModel) = runTest {
// Arrange
val state = if (model.isTokenCurrencyFee) gaslessFeeState() else normalFeeState()
val resultFlow = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow
createSendConfirmModel(this, confirmParams(state))
advanceUntilIdle()
// Act
resultFlow.tryEmit(true)
advanceUntilIdle()
// Assert
if (model.isTokenCurrencyFee) {
coVerify(exactly = 1) { createAndSendGaslessTransactionUseCase(any(), any(), any()) }
coVerify(exactly = 0) { sendTransactionUseCase(any(), any(), any()) }
} else {
coVerify(exactly = 0) { createAndSendGaslessTransactionUseCase(any(), any(), any()) }
coVerify(exactly = 1) { sendTransactionUseCase(any(), any(), any()) }
}
}
private fun provideTestModels() = listOf(
DispatchModel(isTokenCurrencyFee = true),
DispatchModel(isTokenCurrencyFee = false),
)
}
@Nested
inner class VerifyAndSend {
@Test
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 resultFlow = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow
coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right()
val params = MutableParamsContainer(
defaultSendConfirmParams(
state = normalFeeState(),
cryptoCurrencyStatus = loadedFeeStatus,
feeCryptoCurrencyStatus = loadedFeeStatus,
).copy(onSendTransaction = onSendTransaction, callback = callback),
)
createSendConfirmModel(this, params)
advanceUntilIdle()
// Act
resultFlow.tryEmit(true)
advanceUntilIdle()
// Assert
verify(exactly = 1) { onSendTransaction.invoke() }
verify(exactly = 1) { callback.onResult(any()) }
}
@Test
fun `GIVEN transaction creation fails WHEN verifyAndSend THEN show generic error and do NOT send`() = runTest {
// Arrange
val resultFlow = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow
coEvery {
createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any())
} returns IllegalStateException("boom").left()
createSendConfirmModel(this, confirmParams(normalFeeState()))
advanceUntilIdle()
// Act
resultFlow.tryEmit(true)
advanceUntilIdle()
// Assert
verify(exactly = 1) { sendConfirmAlertFactory.getGenericErrorState(any(), any()) }
coVerify(exactly = 0) { sendTransactionUseCase(any(), any(), any()) }
}
}
// region fixtures
private fun confirmParams(state: SendUM) = MutableParamsContainer(
defaultSendConfirmParams(
state = state,
cryptoCurrencyStatus = loadedFeeStatus,
feeCryptoCurrencyStatus = loadedFeeStatus,
),
)
/** Populated Content state with a regular (main-currency) fee — drives the normal send path. */
private fun normalFeeState(): SendUM = contentState(
fee = realFee(),
transactionFeeExtended = null,
)
/** Populated Content state where the extended fee is a gasless token-currency fee. */
private fun gaslessFeeState(): SendUM = contentState(
fee = realFee(),
transactionFeeExtended = TransactionFeeExtended(
transactionFee = TransactionFee.Single(normal = tokenFee()),
feeTokenId = testCryptoCurrency.id,
),
)
private fun contentState(fee: Fee, transactionFeeExtended: TransactionFeeExtended?): SendUM {
val amount = mockk<AmountState.Data>(relaxed = true) {
every { amountTextField.cryptoAmount.value } returns BigDecimal.ONE
every { reduceAmountBy } returns BigDecimal.ZERO
every { isIgnoreReduce } returns false
}
val destination = mockk<DestinationUM.Content>(relaxed = true) {
every { addressTextField.actualAddress } returns "destinationAddr"
every { memoTextField } returns null
every { wallets } returns persistentListOf()
}
val extraInfo = mockk<FeeExtraInfo>(relaxed = true) {
every { this@mockk.transactionFeeExtended } returns transactionFeeExtended
every { feeCryptoCurrencyStatus } returns loadedFeeStatus
}
val feeSelector = mockk<FeeSelectorUM.Content>(relaxed = true) {
every { selectedFeeItem } returns FeeItem.Market(fee)
every { feeNonce } returns FeeNonce.None
every { feeExtraInfo } returns extraInfo
every { isPrimaryButtonEnabled } returns true
}
return SendUM(
amountUM = amount,
destinationUM = destination,
feeSelectorUM = feeSelector,
confirmUM = mockk<ConfirmUM.Content>(relaxed = true),
confirmData = null,
)
}
private val loadedFeeStatus: CryptoCurrencyStatus
get() = com.tangem.features.send.loadedStatus(testCryptoCurrency)
// Can't reuse the shared commonFee(): it builds Amount(blockchain) whose value is null, and
// verifyAndSendTransaction early-returns on `fee.amount.value ?: return` — so the fee needs an explicit value.
private fun realFee(): Fee = Fee.Common(
Amount(currencySymbol = "ETH", value = BigDecimal("0.001"), decimals = 18),
)
private fun tokenFee(): Fee.Ethereum.TokenCurrency = Fee.Ethereum.TokenCurrency(
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.001"), decimals = 18),
gasLimit = java.math.BigInteger.valueOf(21_000),
coinPriceInToken = java.math.BigInteger.ONE,
feeTransferGasLimit = java.math.BigInteger.ONE,
baseGas = java.math.BigInteger.ONE,
)
data class OnSendClickModel(val elapsedRealtime: Long, val expectedSendInitiated: Boolean)
data class CheckFeeResultModel(val checkResult: Boolean, val expectedSendInitiated: Boolean)
data class DispatchModel(val isTokenCurrencyFee: Boolean)
// endregion
}

View file

@ -10,12 +10,12 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.send.api.entity.CustomFeeFieldUM
import com.tangem.features.send.api.entity.FeeExtraInfo
import com.tangem.features.send.api.entity.FeeFiatRateUM
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeNonce
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM
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.common.ui.state.ConfirmUM
import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2
import io.mockk.mockk
@ -381,7 +381,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test {
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),

Some files were not shown because too many files have changed in this diff Show more