Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 13:39:30 +03:00
commit 92849d43c2
1407 changed files with 64092 additions and 11990 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
@ -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

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

@ -28,27 +28,27 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
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.SendAmountComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
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.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.amount.DefaultSendAmountComponent
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
import kotlinx.coroutines.launch
@ -57,7 +57,9 @@ 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 feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
private val sendDestinationComponentFactory: SendDestinationComponent.Factory,
) : SendComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<CommonSendRoute>()
@ -112,7 +114,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
activeComponent.updateState(model.uiState.value)
}
}
is SendAmountComponent -> {
is DefaultSendAmountComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AmountScreenOpened(
categoryName = model.analyticCategoryName,
@ -122,7 +124,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
)
activeComponent.updateState(model.uiState.value.amountUM)
}
is DefaultSendDestinationComponent -> {
is SendDestinationComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AddressScreenOpened(
categoryName = model.analyticCategoryName,
@ -162,9 +164,9 @@ internal class DefaultSendComponent @AssistedInject constructor(
is CommonSendRoute.ConfirmSuccess -> getConfirmSuccessComponent(factoryContext)
}
private fun getDestinationComponent(factoryContext: AppComponentContext): DefaultSendDestinationComponent =
DefaultSendDestinationComponent(
appComponentContext = factoryContext,
private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent =
sendDestinationComponentFactory.create(
context = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = model.currentRoute.filterIsInstance<CommonSendRoute.Destination>(),
@ -179,11 +181,11 @@ internal class DefaultSendComponent @AssistedInject constructor(
)
private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent {
return SendAmountComponent(
appComponentContext = factoryContext,
return amountComponentFactory.create(
context = factoryContext,
params = SendAmountComponentParams.AmountParams(
state = model.uiState.value.amountUM,
currentRoute = model.currentRoute.asStateFlow(),
currentRoute = model.currentRoute.filterIsInstance<CommonSendRoute.Amount>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName,
appCurrency = model.appCurrency,
@ -261,6 +263,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
cryptoCurrency = cryptoCurrencyStatus.currency,
blockClickEnableFlow = MutableStateFlow(true),
predefinedValues = model.predefinedValues,
isAddContactAvailable = true,
),
onResult = { },
onClick = {},

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

@ -16,20 +16,19 @@ 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.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.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.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent
import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent
import com.tangem.utils.extensions.orZero
@ -61,7 +60,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 +106,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(),

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

@ -34,6 +34,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 +44,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 +72,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 +81,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 +115,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 {
@ -503,6 +507,7 @@ internal class SendConfirmModel @Inject constructor(
private fun updateConfirmNotifications() {
modelScope.launch {
val feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment()
notificationsUpdateTrigger.triggerUpdate(
data = NotificationData(
destinationAddress = confirmData.enteredDestination.orEmpty(),
@ -512,9 +517,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 +537,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,6 +546,12 @@ internal class SendConfirmModel @Inject constructor(
}
}
private suspend fun isHighNetworkFee(feeCurrency: CryptoCurrency): Boolean {
if (!sendFeatureToggles.isHighFeeWarningEnabled) return false
val feeAmount = confirmData.fee?.amount?.value ?: return false
return isHighNetworkFeeUseCase(feeCurrency, feeAmount)
}
@Suppress("LongMethod")
private fun configConfirmNavigation() {
combine(

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

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

View file

@ -25,8 +25,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) {

View file

@ -2,7 +2,7 @@ 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.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.send.confirm.model.ConfirmData

View file

@ -20,15 +20,15 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
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.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.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
@ -41,6 +41,7 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
private val nftSendConfirmComponentFactory: NFTSendConfirmComponent.Factory,
private val nftSendSuccessComponentFactory: NFTSendSuccessComponent.Factory,
private val analyticsEventHandler: AnalyticsEventHandler,
private val sendDestinationComponentFactory: SendDestinationComponent.Factory,
) : NFTSendComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<CommonSendRoute>()
@ -96,7 +97,7 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
activeComponent.updateState(model.uiState.value)
}
}
is DefaultSendDestinationComponent -> {
is SendDestinationComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AddressScreenOpened(
categoryName = analyticsCategoryName,
@ -131,9 +132,9 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
else -> getStubComponent()
}
private fun getDestinationComponent(factoryContext: AppComponentContext): DefaultSendDestinationComponent =
DefaultSendDestinationComponent(
appComponentContext = factoryContext,
private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent =
sendDestinationComponentFactory.create(
context = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = model.currentRouteFlow.filterIsInstance<CommonSendRoute.Destination>(),

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

@ -19,12 +19,12 @@ 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.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.params.FeeSelectorParams.FeeStateConfiguration
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.state.ConfirmUM

View file

@ -32,11 +32,11 @@ 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.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger
@ -62,7 +62,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

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

@ -36,7 +36,7 @@ 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.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.common.CommonSendRoute

View file

@ -63,6 +63,7 @@ internal class NFTSendSuccessComponent @AssistedInject constructor(
cryptoCurrency = params.cryptoCurrencyStatus.currency,
blockClickEnableFlow = MutableStateFlow(false),
predefinedValues = PredefinedValues.Empty,
isAddContactAvailable = true,
),
onResult = {},
onClick = {},

View file

@ -1,7 +1,7 @@
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.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.common.ui.state.ConfirmUM

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

@ -5,22 +5,24 @@ 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.api.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
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 SendAmountComponent(
appComponentContext: AppComponentContext,
private val params: SendAmountComponentParams.AmountParams,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
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)
fun updateState(amountUM: AmountState) = model.updateState(amountUM)
override fun updateState(amountUM: AmountState) = model.updateState(amountUM)
@Composable
override fun Content(modifier: Modifier) {
@ -35,10 +37,11 @@ internal class SendAmountComponent(
)
}
interface ModelCallback : NavigationModelCallback {
fun onAmountResult(amountUM: AmountState, isResetPredefined: Boolean)
fun onConvertToAnotherToken(lastAmount: String, isEnterInFiatSelected: Boolean)
fun resetSendNavigation()
fun onError(error: GetUserWalletError)
@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,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,13 @@ 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.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
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 +29,21 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.entity.isFromMainScreenQr
import com.tangem.features.send.api.subcomponents.amount.AmountRoute
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents.SelectedCurrencyType
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.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
@ -271,13 +273,17 @@ 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 {
var isEditMode = false
amountParams.currentRoute.collect { route -> isEditMode = route.isEditMode }
if (isEditMode) {
sendAmountAlertFactory.showResetSendingAlert {
params.callback.resetSendNavigation()
confirmConvertToToken()
}
} else {
confirmConvertToToken()
}
} else {
confirmConvertToToken()
}
}
@ -361,7 +367,9 @@ internal class SendAmountModel @Inject constructor(
val params = params as? SendAmountComponentParams.AmountParams ?: return
combine(
flow = uiState,
flow2 = params.currentRoute.filterIsInstance<CommonSendRoute.Amount>(),
// Filter on the public AmountRoute interface (not the internal CommonSendRoute.Amount) so an
// external host (e.g. staking) that supplies its own AmountRoute is not silently dropped here.
flow2 = params.currentRoute.filterIsInstance<AmountRoute>(),
transform = { state, route -> state to route },
).onEach { (state, route) ->
setSendWithSwapAvailability()

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

@ -4,8 +4,16 @@ 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.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.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
@ -18,18 +26,61 @@ 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 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()
}
@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,7 +3,11 @@ 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.routing.entity.AddressBookOpenMode
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -15,6 +19,8 @@ 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.model.Contact
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
@ -34,6 +40,9 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.api.entity.PredefinedValues
@ -41,18 +50,12 @@ import com.tangem.features.send.api.subcomponents.destination.SendDestinationCom
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.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.ui.state.DestinationWalletUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory
import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents
import com.tangem.features.send.subcomponents.destination.model.transformers.*
import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -88,6 +91,8 @@ internal class SendDestinationModel @Inject constructor(
private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase,
private val sendDestinationAlertFactory: SendDestinationAlertFactory,
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase,
getContactsUseCase: GetContactsUseCase,
contactSelectionListener: ContactSelectionListener,
) : Model(), SendDestinationClickIntents {
private val params: SendDestinationComponentParams = paramsContainer.require()
@ -98,6 +103,36 @@ internal class SendDestinationModel @Inject constructor(
private val cryptoCurrency = params.cryptoCurrency
private val userWalletId = params.userWalletId
private val contacts: StateFlow<List<Contact>> = getContactsUseCase(query = "", userWalletId = null)
.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())
@ -110,6 +145,61 @@ internal class SendDestinationModel @Inject constructor(
configDestinationNavigation()
subscribeOnQRScannerResult()
initialState()
resetContactOnEdit()
contactSelectionListener.resultFlow
.onEach(::applySelectedContact)
.launchIn(modelScope)
}
private fun resetContactOnEdit() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return
params.currentRoute
.filter { it.isEditMode }
.onEach {
val content = uiState.value as? DestinationUM.Content ?: return@onEach
if (content.addressTextField.contactName != null) {
_uiState.update(SendDestinationContactTransformer(contact = null))
}
}
.launchIn(modelScope)
}
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()
_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() {
@ -371,6 +461,7 @@ internal class SendDestinationModel @Inject constructor(
isMemoRequired = isMemoRequired,
),
)
recognizeContact(type = type, isValidAddress = addressValidationResult.isRight(), address = resolvedAddress)
if (type != null) {
autoNextFromRecipient(
type = type,
@ -381,6 +472,21 @@ internal class SendDestinationModel @Inject constructor(
}.saveIn(validationJobHolder)
}
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)
}
}
}
private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) {
if (type.isAutoNext && isValidAddress && isValidMemo) {
saveResult()

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,31 +84,42 @@ 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,
text = contactName ?: address.value,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS),
)
val blockchainAddress = address.briefBlockchainAddress
if (!blockchainAddress.isNullOrBlank()) {
val recipient = if (contactName != null) address.value else address.briefBlockchainAddress
if (!recipient.isNullOrBlank()) {
Text(
text = blockchainAddress,
text = recipient,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.testTag(SendConfirmScreenTestTags.BLOCKCHAIN_ADDRESS),
)
}
}
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.Medium,
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,288 @@
package com.tangem.features.send.send
import arrow.core.Either
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.routing.AppRouter
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.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.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.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
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.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
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.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.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.send.analytics.SendAnalyticHelper
import com.tangem.features.send.send.confirm.SendConfirmComponent
import com.tangem.features.send.send.confirm.model.SendConfirmModel
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.send.testDispatcherProvider
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.qrscanning.models.SourceType
import arrow.core.right
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.send.model.SendModel
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import 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,
navigationUM = NavigationUM.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),
currentRoute = kotlinx.coroutines.flow.flowOf(),
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,288 @@
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.common.ui.navigationButtons.NavigationUM
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.send.SendModelTestBase
import com.tangem.test.core.ProvideTestModels
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.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),
navigationUM = NavigationUM.Empty,
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"),

View file

@ -1,9 +1,9 @@
package com.tangem.features.send.v2.send.confirm.model.transformers
package com.tangem.features.send.send.confirm.model.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.google.common.truth.Truth
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
@ -17,16 +17,10 @@ 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.domain.tokens.model.Amount
import com.tangem.features.send.api.subcomponents.feeSelector.entity.*
import com.tangem.features.send.api.utils.formatFooterFiatFee
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.send.confirm.model.transformers.SendConfirmationNotificationsTransformerV2
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
@ -36,7 +30,6 @@ import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.BigInteger
import java.util.Locale
import com.tangem.domain.tokens.model.Amount as DomainAmount
class SendConfirmationNotificationsTransformerV2Test {
@ -84,6 +77,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
isFeeSubtractedFromAmount = false,
isFeeExceedingBalance = false,
)
@ -93,7 +87,7 @@ class SendConfirmationNotificationsTransformerV2Test {
val result = transformer.transform(initialState)
// THEN
assertThat(result).isEqualTo(initialState)
Truth.assertThat(result).isEqualTo(initialState)
}
@Test
@ -108,6 +102,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
isFeeSubtractedFromAmount = false,
isFeeExceedingBalance = false,
)
@ -117,7 +112,7 @@ class SendConfirmationNotificationsTransformerV2Test {
val result = transformer.transform(initialState)
// THEN
assertThat(result).isEqualTo(initialState)
Truth.assertThat(result).isEqualTo(initialState)
}
@Test
@ -132,6 +127,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
isFeeSubtractedFromAmount = false,
isFeeExceedingBalance = false,
)
@ -141,10 +137,10 @@ class SendConfirmationNotificationsTransformerV2Test {
val result = transformer.transform(initialState)
// THEN
assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
val content = result as ConfirmUM.Content
assertThat(content.notifications).isEmpty()
assertThat(content.sendingFooter).isNotEqualTo(initialState.sendingFooter)
Truth.assertThat(content.notifications).isEmpty()
Truth.assertThat(content.sendingFooter).isNotEqualTo(initialState.sendingFooter)
}
@Test
@ -168,7 +164,7 @@ class SendConfirmationNotificationsTransformerV2Test {
// THEN: sending = entered fiat amount (50.00), fee NOT added on top.
val content = result as ConfirmUM.Content
assertThat(content.sendingFooter).isEqualTo(
Truth.assertThat(content.sendingFooter).isEqualTo(
expectedFiatFooter(sendingValue = BigDecimal("50.00"), feeSelectorUM = feeSelectorUM),
)
}
@ -194,7 +190,7 @@ class SendConfirmationNotificationsTransformerV2Test {
// THEN: sending = $0.
val content = result as ConfirmUM.Content
assertThat(content.sendingFooter).isEqualTo(
Truth.assertThat(content.sendingFooter).isEqualTo(
expectedFiatFooter(sendingValue = BigDecimal.ZERO, feeSelectorUM = feeSelectorUM),
)
}
@ -211,6 +207,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
isFeeSubtractedFromAmount = false,
isFeeExceedingBalance = false,
)
@ -220,10 +217,37 @@ class SendConfirmationNotificationsTransformerV2Test {
val result = transformer.transform(initialState)
// THEN
assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
val content = result as ConfirmUM.Content
assertThat(content.notifications).hasSize(1)
assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java)
Truth.assertThat(content.notifications).hasSize(1)
Truth.assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java)
}
@Test
fun `GIVEN high network fee WHEN transform THEN returns state with high network fee notification`() = runTest {
// GIVEN
val feeSelectorUM = createNormalFeeSelectorUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = true,
isFeeSubtractedFromAmount = false,
isFeeExceedingBalance = false,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
val content = result as ConfirmUM.Content
Truth.assertThat(content.notifications).containsExactly(NotificationUM.Warning.HighNetworkFee)
}
@Test
@ -238,6 +262,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
isFeeSubtractedFromAmount = false,
isFeeExceedingBalance = false,
)
@ -247,10 +272,10 @@ class SendConfirmationNotificationsTransformerV2Test {
val result = transformer.transform(initialState)
// THEN
assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
val content = result as ConfirmUM.Content
assertThat(content.notifications).hasSize(1)
assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java)
Truth.assertThat(content.notifications).hasSize(1)
Truth.assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java)
verify { analyticsEventHandler.send(any()) }
}
@ -266,6 +291,7 @@ class SendConfirmationNotificationsTransformerV2Test {
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
isHighNetworkFee = false,
isFeeSubtractedFromAmount = false,
isFeeExceedingBalance = false,
)
@ -275,11 +301,11 @@ class SendConfirmationNotificationsTransformerV2Test {
val result = transformer.transform(initialState)
// THEN
assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
Truth.assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
val content = result as ConfirmUM.Content
assertThat(content.notifications).hasSize(2)
assertThat(content.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue()
assertThat(content.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue()
Truth.assertThat(content.notifications).hasSize(2)
Truth.assertThat(content.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue()
Truth.assertThat(content.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue()
}
private fun createTestConfirmUM(): ConfirmUM.Content {
@ -294,12 +320,12 @@ class SendConfirmationNotificationsTransformerV2Test {
}
private fun createTestAmountUM(): AmountState.Data {
val cryptoAmount = DomainAmount(
val cryptoAmount = Amount(
currencySymbol = "SOL",
value = BigDecimal("1.5"),
decimals = 8,
)
val fiatAmount = DomainAmount(
val fiatAmount = Amount(
currencySymbol = "USD",
value = BigDecimal("50.00"),
decimals = 2,
@ -336,7 +362,13 @@ class SendConfirmationNotificationsTransformerV2Test {
}
private fun createFiatConvertibleFeeSelectorUM(feeValue: BigDecimal): FeeSelectorUM.Content {
val fee = Fee.Common(amount = Amount(currencySymbol = "SOL", value = feeValue, decimals = 8))
val fee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = feeValue,
decimals = 8
)
)
return FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
fees = TransactionFee.Single(fee),
@ -374,7 +406,7 @@ class SendConfirmationNotificationsTransformerV2Test {
private fun createNormalFeeSelectorUM(): FeeSelectorUM.Content {
val fee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
@ -405,14 +437,14 @@ class SendConfirmationNotificationsTransformerV2Test {
private fun createFeeTooHighUM(): FeeSelectorUM.Content {
val priorityFee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
@ -429,7 +461,7 @@ class SendConfirmationNotificationsTransformerV2Test {
feeItems = persistentListOf(
FeeItem.Custom(
fee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.01"),
decimals = 8,
@ -451,7 +483,7 @@ class SendConfirmationNotificationsTransformerV2Test {
),
selectedFeeItem = FeeItem.Custom(
fee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.01"),
decimals = 8,
@ -489,14 +521,14 @@ class SendConfirmationNotificationsTransformerV2Test {
private fun createFeeTooLowUM(): FeeSelectorUM.Content {
val fee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.0001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
@ -561,14 +593,14 @@ class SendConfirmationNotificationsTransformerV2Test {
private fun createFeeTooHighAndTooLowUM(): FeeSelectorUM.Content {
val priorityFee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.01"),
decimals = 8,
@ -585,7 +617,7 @@ class SendConfirmationNotificationsTransformerV2Test {
feeItems = persistentListOf(
FeeItem.Custom(
fee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.008"),
decimals = 8,
@ -607,7 +639,7 @@ class SendConfirmationNotificationsTransformerV2Test {
),
selectedFeeItem = FeeItem.Custom(
fee = Fee.Common(
amount = Amount(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.008"),
decimals = 8,

View file

@ -0,0 +1,265 @@
package com.tangem.features.send.send.model
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.send.SendModelTestBase
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM as FeeSelectorUMRedesigned
@OptIn(ExperimentalCoroutinesApi::class)
internal class SendModelTest : SendModelTestBase() {
@Nested
inner class OnNextClick {
@Test
fun `GIVEN amount route AND predefined main screen QR WHEN onNextClick THEN push Confirm`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
model.predefinedValues = PredefinedValues.Content.QrCode(
amount = "1.0",
address = "addr123",
memo = null,
source = PredefinedValues.Source.MAIN_SCREEN,
)
// Act
model.onNextClick()
// Assert
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
}
@Test
fun `GIVEN amount route AND NOT main screen QR WHEN onNextClick THEN push Destination`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
model.predefinedValues = PredefinedValues.Empty
// Act
model.onNextClick()
// Assert
verify(exactly = 1) { router.push(CommonSendRoute.Destination(isEditMode = false), any()) }
}
@Test
fun `GIVEN destination route WHEN onNextClick THEN push Confirm`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Destination(isEditMode = false)
// Act
model.onNextClick()
// Assert
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
}
@Test
fun `GIVEN route in edit mode WHEN onNextClick THEN pop without push`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = true)
// Act
model.onNextClick()
// Assert
verify(exactly = 1) { router.pop(any()) }
verify(exactly = 0) { router.push(any(), any()) }
}
@Test
fun `GIVEN confirm route WHEN onNextClick THEN pop (Confirm isEditMode is true so push branch is dead)`() =
runTest {
// Arrange
// CommonSendRoute.Confirm.isEditMode == true, so onNextClick short-circuits to onBackClick().
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Confirm
// Act
model.onNextClick()
// Assert
verify(exactly = 1) { router.pop(any()) }
verify(exactly = 0) { router.push(CommonSendRoute.ConfirmSuccess, any()) }
}
}
@Nested
inner class ConsumeEntryType {
@Test
fun `GIVEN entry type QR WHEN consumeEntryType first call THEN return QR`() = runTest {
// Arrange
val params = defaultSendParams().copy(entryType = SendComponent.EntryType.QR)
val model = createSendModel(this, MutableParamsContainer(params))
// Act
val result = model.consumeEntryType()
// Assert
assertThat(result).isEqualTo(CommonSendAnalyticEvents.SendEntryType.QR)
}
@Test
fun `GIVEN entry type QR WHEN consumeEntryType called twice THEN second returns Manual`() = runTest {
// Arrange
val params = defaultSendParams().copy(entryType = SendComponent.EntryType.QR)
val model = createSendModel(this, MutableParamsContainer(params))
// Act
val first = model.consumeEntryType()
val second = model.consumeEntryType()
// Assert
assertThat(first).isEqualTo(CommonSendAnalyticEvents.SendEntryType.QR)
assertThat(second).isEqualTo(CommonSendAnalyticEvents.SendEntryType.Manual)
}
@Test
fun `GIVEN entry type Manual WHEN consumeEntryType THEN return Manual`() = runTest {
// Arrange
val params = defaultSendParams().copy(entryType = SendComponent.EntryType.Manual)
val model = createSendModel(this, MutableParamsContainer(params))
// Act
val result = model.consumeEntryType()
// Assert
assertThat(result).isEqualTo(CommonSendAnalyticEvents.SendEntryType.Manual)
}
}
@Nested
inner class LoadFee {
@Test
fun `GIVEN transaction created WHEN loadFee THEN return fee from use case`() = runTest {
// Arrange
val model = createSendModel(this)
advanceUntilIdle()
model.predefinedValues = deeplink(amount = "1.0")
val expectedFee = mockk<TransactionFee>(relaxed = true)
coEvery { getFeeUseCase(any(), any(), any()) } returns expectedFee.right()
// Act
val result = model.loadFee()
// Assert
assertThat(result).isEqualTo(expectedFee.right())
}
@Test
fun `GIVEN transaction creation fails WHEN loadFee THEN return DataError`() = runTest {
// Arrange
val model = createSendModel(this)
advanceUntilIdle()
model.predefinedValues = deeplink(amount = "1.0")
coEvery {
createTransferTransactionUseCase(any(), any<String>(), any(), any(), any(), any())
} returns IllegalStateException("boom").left()
// Act
val result = model.loadFee()
// Assert
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java)
}
@Test
fun `GIVEN fee use case fails WHEN loadFee THEN return that error`() = runTest {
// Arrange
val model = createSendModel(this)
advanceUntilIdle()
model.predefinedValues = deeplink(amount = "1.0")
coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
// Act
val result = model.loadFee()
// Assert
assertThat(result).isEqualTo(GetFeeError.UnknownError.left())
}
}
@Nested
inner class OnBackClick {
@Test
fun `GIVEN amount route non-edit WHEN onBackClick THEN send analytics and pop`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
// Act
model.onBackClick()
// Assert
verify(exactly = 1) { analyticsEventHandler.send(any<CommonSendAnalyticEvents.CloseButtonClicked>()) }
verify(exactly = 1) { router.pop(any()) }
}
@Test
fun `GIVEN destination route edit WHEN onBackClick THEN pop without analytics`() = runTest {
// Arrange
val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Destination(isEditMode = true)
// Act
model.onBackClick()
// Assert
verify(exactly = 0) { analyticsEventHandler.send(any<CommonSendAnalyticEvents.CloseButtonClicked>()) }
verify(exactly = 1) { router.pop(any()) }
}
}
@Nested
inner class ResetSendNavigation {
@Test
fun `GIVEN any state WHEN resetSendNavigation THEN reset states and popTo Amount`() = runTest {
// Arrange
val model = createSendModel(this)
// Act
model.resetSendNavigation()
// Assert
val state = model.uiState.value
assertThat(state.feeSelectorUM).isEqualTo(FeeSelectorUMRedesigned.Loading)
assertThat(state.confirmUM).isEqualTo(ConfirmUM.Empty)
assertThat(state.confirmData).isNull()
assertThat(state.navigationUM).isEqualTo(NavigationUM.Empty)
verify(exactly = 1) { router.popTo(CommonSendRoute.Amount(isEditMode = false), any()) }
}
}
private fun deeplink(amount: String) = PredefinedValues.Content.Deeplink(
amount = amount,
address = "addr123",
memo = null,
transactionId = "tx123",
)
}

View file

@ -0,0 +1,344 @@
package com.tangem.features.send.sendnft.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.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter
import com.tangem.domain.appcurrency.model.AppCurrency
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.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
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.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.common.SendBalanceUpdater
import com.tangem.features.send.common.SendConfirmAlertFactory
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.loadedStatus
import com.tangem.features.send.testDispatcherProvider
import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper
import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent
import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import com.tangem.test.core.ProvideTestModels
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
internal class NFTSendConfirmModelTest {
private val network: Network = mockk(relaxed = true)
private val nftAsset: NFTAsset = mockk(relaxed = true)
private val testUserWallet: UserWallet = mockk(relaxed = true)
private val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) {
every { this@mockk.network } returns this@NFTSendConfirmModelTest.network
}
private val router: Router = mockk(relaxed = true)
private val appRouter: AppRouter = mockk(relaxed = true)
private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase = mockk(relaxed = true)
private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase = mockk(relaxed = true)
private val createNFTTransferTransactionUseCase: CreateNFTTransferTransactionUseCase = mockk(relaxed = true)
private val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true)
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase = mockk(relaxed = true)
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk(relaxed = true)
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk(relaxed = true)
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxed = true)
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger = mockk(relaxed = true)
private val notificationsUpdateListener: SendNotificationsUpdateListener = mockk(relaxed = true)
private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger = mockk(relaxed = true)
private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener = mockk(relaxed = true)
private val alertFactory: SendConfirmAlertFactory = mockk(relaxed = true)
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val shareManager: ShareManager = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val nftSendAnalyticHelper: NFTSendAnalyticHelper = mockk(relaxed = true)
private val nftSendSuccessTrigger: NFTSendSuccessTrigger = mockk(relaxed = true)
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true)
private val sendBalanceUpdaterFactory: SendBalanceUpdater.Factory = mockk(relaxed = true)
private val loadedStatus: CryptoCurrencyStatus get() = loadedStatus(testCryptoCurrency)
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns 0L
mockkObject(NFTSdkAssetConverter)
every { NFTSdkAssetConverter.convertBack(any()) } returns (network to mockk<SdkNFTAsset>(relaxed = true))
clearMocks(
createNFTTransferTransactionUseCase,
sendTransactionUseCase,
feeSelectorCheckReloadTrigger,
alertFactory,
answers = false,
recordedCalls = true,
childMocks = false,
)
coEvery { isSendTapHelpEnabledUseCase.invokeSync() } returns false.right()
every { isSendTapHelpEnabledUseCase() } returns emptyFlow<Boolean>().right()
every { notificationsUpdateListener.hasErrorFlow } returns emptyFlow()
every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns emptyFlow()
coEvery {
createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any())
} returns mockk<TransactionData.Uncompiled>(relaxed = true).right()
coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right()
every { getExplorerTransactionUrlUseCase(any(), any()) } returns "https://explorer/tx".right()
every { sendBalanceUpdaterFactory.create(any(), any()) } returns mockk(relaxed = true)
}
@AfterEach
fun tearDown() {
unmockkStatic(SystemClock::class)
unmockkObject(NFTSdkAssetConverter)
}
@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 = buildModel()
advanceUntilIdle()
// Act
sut.onSendClick()
advanceUntilIdle()
// Assert
if (model.expectedSendInitiated) {
coVerify(exactly = 1) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
} else {
coVerify(exactly = 0) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) }
coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
}
}
private fun provideTestModels() = listOf(
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
buildModel()
advanceUntilIdle()
// Act
resultFlow.tryEmit(model.checkResult)
advanceUntilIdle()
// Assert
coVerify(exactly = model.expectedCreateNFTTTransferCalls) {
createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any())
}
}
private fun provideTestModels() = listOf(
CheckFeeResultModel(checkResult = true, expectedCreateNFTTTransferCalls = 1),
CheckFeeResultModel(checkResult = false, expectedCreateNFTTTransferCalls = 0),
)
}
@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<NFTSendConfirmComponent.ModelCallback>(relaxed = true)
val resultFlow = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow
buildModel(
paramsContainer = MutableParamsContainer(
defaultParams().copy(onSendTransaction = onSendTransaction, callback = callback),
),
)
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 {
createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any())
} returns IllegalStateException("boom").left()
buildModel()
advanceUntilIdle()
// Act
resultFlow.tryEmit(true)
advanceUntilIdle()
// Assert
verify(exactly = 1) { alertFactory.getGenericErrorState(any(), any()) }
coVerify(exactly = 0) { sendTransactionUseCase(any(), any(), any()) }
}
}
// region fixtures
private fun TestScope.buildModel(
paramsContainer: ParamsContainer = MutableParamsContainer(defaultParams()),
): NFTSendConfirmModel {
return NFTSendConfirmModel(
paramsContainer = paramsContainer,
dispatchers = testDispatcherProvider(),
router = router,
appRouter = appRouter,
isSendTapHelpEnabledUseCase = isSendTapHelpEnabledUseCase,
neverShowTapHelpUseCase = neverShowTapHelpUseCase,
createNFTTransferTransactionUseCase = createNFTTransferTransactionUseCase,
sendTransactionUseCase = sendTransactionUseCase,
getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase,
saveBlockchainErrorUseCase = saveBlockchainErrorUseCase,
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
notificationsUpdateTrigger = notificationsUpdateTrigger,
notificationsUpdateListener = notificationsUpdateListener,
feeSelectorCheckReloadTrigger = feeSelectorCheckReloadTrigger,
feeSelectorCheckReloadListener = feeSelectorCheckReloadListener,
alertFactory = alertFactory,
urlOpener = urlOpener,
shareManager = shareManager,
analyticsEventHandler = analyticsEventHandler,
nftSendAnalyticHelper = nftSendAnalyticHelper,
nftSendSuccessTrigger = nftSendSuccessTrigger,
feeSelectorReloadTrigger = feeSelectorReloadTrigger,
sendBalanceUpdaterFactory = sendBalanceUpdaterFactory,
)
}
private fun defaultParams(): NFTSendConfirmComponent.Params = NFTSendConfirmComponent.Params(
state = contentState(),
analyticsCategoryName = "test_nft_send",
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.NFT,
userWallet = testUserWallet,
appCurrency = AppCurrency.Default,
nftAsset = nftAsset,
nftCollectionName = "Collection",
cryptoCurrencyStatus = loadedStatus,
feeCryptoCurrencyStatus = loadedStatus,
account = null,
isAccountsMode = false,
callback = mockk(relaxed = true),
currentRoute = flowOf(),
isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false),
onLoadFee = { mockk<com.tangem.blockchain.common.transaction.TransactionFee>(relaxed = true).right() },
onSendTransaction = {},
)
private fun contentState(): NFTSendUM {
val destination = mockk<DestinationUM.Content>(relaxed = true) {
every { addressTextField.actualAddress } returns "destinationAddr"
every { memoTextField } returns null
}
val extraInfo = mockk<FeeExtraInfo>(relaxed = true) {
every { transactionFeeExtended } returns null
every { feeCryptoCurrencyStatus } returns loadedStatus
}
val feeSelector = mockk<FeeSelectorUM.Content>(relaxed = true) {
every { selectedFeeItem } returns FeeItem.Market(realFee())
every { feeNonce } returns FeeNonce.None
every { feeExtraInfo } returns extraInfo
every { isPrimaryButtonEnabled } returns true
}
return NFTSendUM(
destinationUM = destination,
feeSelectorUM = feeSelector,
confirmUM = mockk<ConfirmUM.Content>(relaxed = true),
navigationUM = NavigationUM.Empty,
)
}
private fun realFee(): Fee = Fee.Common(
Amount(currencySymbol = "ETH", value = BigDecimal("0.001"), decimals = 18),
)
data class OnSendClickModel(val elapsedRealtime: Long, val expectedSendInitiated: Boolean)
data class CheckFeeResultModel(val checkResult: Boolean, val expectedCreateNFTTTransferCalls: Int)
// endregion
}

View file

@ -0,0 +1,229 @@
package com.tangem.features.send.sendnft.model
import arrow.core.left
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
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.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
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.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.wallets.models.errors.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.nft.entity.NFTSendSuccessTrigger
import com.tangem.features.send.api.NFTSendComponent
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.SendConfirmAlertFactory
import com.tangem.features.send.testDispatcherProvider
import com.tangem.test.core.ProvideTestModels
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.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
@OptIn(ExperimentalCoroutinesApi::class)
internal class NFTSendModelTest {
private val testUserWalletId = UserWalletId("1234567890ABCDEF")
private val network: Network = mockk(relaxed = true)
private val nftAsset: com.tangem.domain.nft.models.NFTAsset = mockk(relaxed = true)
private val testUserWallet: UserWallet = mockk(relaxed = true)
private val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true)
private val coin: CryptoCurrency.Coin = mockk(relaxed = true)
private val router: Router = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true)
private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true)
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase =
mockk(relaxed = true)
private val createNFTTransferTransactionUseCase: CreateNFTTransferTransactionUseCase = mockk(relaxed = true)
private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk(relaxed = true)
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk(relaxed = true)
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxed = true)
private val alertFactory: SendConfirmAlertFactory = mockk(relaxed = true)
private val nftSendSuccessTrigger: NFTSendSuccessTrigger = mockk(relaxed = true)
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk(relaxed = true)
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
// PER_CLASS parameterized nested classes reuse one instance — reset recorded calls between rows.
clearMocks(router, nftSendSuccessTrigger, alertFactory, answers = false, recordedCalls = true, childMocks = false)
every { nftAsset.network } returns network
every { coin.network } returns network
every { getUserWalletUseCase(testUserWalletId) } returns testUserWallet.right()
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any(), any()) } returns null
every { getAccountCurrencyStatusUseCase(any(), any()) } returns emptyFlow()
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns testCryptoCurrencyStatus.right()
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnNextClick {
@ParameterizedTest
@ProvideTestModels
fun `WHEN onNextClick THEN push Confirm for destination else navigate back`(model: NextClickModel) = runTest {
// Arrange
val sut = buildModel()
advanceUntilIdle()
sut.currentRouteFlow.value = model.route
// Act
sut.onNextClick()
advanceUntilIdle()
// Assert
if (model.expectPushConfirm) {
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
verify(exactly = 0) { router.pop(any()) }
} else {
verify(exactly = 1) { router.pop(any()) }
verify(exactly = 0) { router.push(any(), any()) }
// Confirm.isEditMode == true, so the `Confirm -> replaceAll(ConfirmSuccess)` branch is unreachable
verify(exactly = 0) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = any()) }
}
}
private fun provideTestModels() = listOf(
NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectPushConfirm = true),
NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectPushConfirm = false),
NextClickModel(route = CommonSendRoute.Confirm, expectPushConfirm = false),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnBackClick {
@ParameterizedTest
@ProvideTestModels
fun `WHEN onBackClick THEN trigger success only from ConfirmSuccess and always pop`(model: BackClickModel) =
runTest {
// Arrange
val sut = buildModel()
advanceUntilIdle()
sut.currentRouteFlow.value = model.route
// Act
sut.onBackClick()
advanceUntilIdle()
// Assert
coVerify(exactly = model.expectedTriggerCalls) { nftSendSuccessTrigger.triggerSuccessNFTSend() }
verify(exactly = 1) { router.pop(any()) }
}
private fun provideTestModels() = listOf(
BackClickModel(route = CommonSendRoute.ConfirmSuccess, expectedTriggerCalls = 1),
BackClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectedTriggerCalls = 0),
)
}
@Nested
inner class SubscribeOnCurrencyStatusUpdates {
@Test
fun `GIVEN get user wallet fails WHEN init THEN show generic error`() = runTest {
// Arrange
every { getUserWalletUseCase(testUserWalletId) } returns GetUserWalletError.UserWalletNotFound.left()
// Act
buildModel()
advanceUntilIdle()
// Assert
verify(exactly = 1) { alertFactory.getGenericErrorState(any(), any()) }
}
@Test
fun `GIVEN currency status loaded with empty destination WHEN init THEN navigate to destination`() = runTest {
// Arrange
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any(), any()) } returns setOf(coin)
val accountStatus = mockk<AccountCryptoCurrencyStatus> {
every { component1() } returns mockk(relaxed = true)
every { component2() } returns testCryptoCurrencyStatus
}
every { getAccountCurrencyStatusUseCase(testUserWalletId, coin) } returns flowOf(accountStatus)
// Act
buildModel()
advanceUntilIdle()
// Assert
verify(exactly = 1) {
router.replaceAll(CommonSendRoute.Destination(isEditMode = false), onComplete = any())
}
}
}
// region fixtures
private fun TestScope.buildModel(): NFTSendModel {
val params = NFTSendComponent.Params(
userWalletId = testUserWalletId,
nftAsset = nftAsset,
nftCollectionName = "Collection",
)
return NFTSendModel(
paramsContainer = MutableParamsContainer(params),
dispatchers = testDispatcherProvider(),
router = router,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
getUserWalletUseCase = getUserWalletUseCase,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
createNFTTransferTransactionUseCase = createNFTTransferTransactionUseCase,
getFeeUseCase = getFeeUseCase,
saveBlockchainErrorUseCase = saveBlockchainErrorUseCase,
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
alertFactory = alertFactory,
nftSendSuccessTrigger = nftSendSuccessTrigger,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase,
analyticsEventHandler = analyticsEventHandler,
)
}
data class NextClickModel(val route: CommonSendRoute, val expectPushConfirm: Boolean)
data class BackClickModel(val route: CommonSendRoute, val expectedTriggerCalls: Int)
// endregion
}

View file

@ -0,0 +1,321 @@
package com.tangem.features.send.subcomponents.amount.model
import arrow.core.right
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
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.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.loadedStatus
import com.tangem.features.send.testDispatcherProvider
import com.tangem.features.send.api.subcomponents.amount.AmountRoute
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.test.core.ProvideTestModels
import com.google.common.truth.Truth.assertThat
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
internal class SendAmountModelTest {
private val testUserWalletId = UserWalletId("1234567890ABCDEF")
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) {
every { isCustom } returns false
}
private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase = mockk(relaxed = true)
private val sendAmountReduceListener: SendAmountReduceListener = mockk(relaxed = true)
private val sendAmountUpdateListener: SendAmountUpdateListener = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true)
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true)
private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
private val sendAmountAlertFactory: SendAmountAlertFactory = mockk(relaxed = true)
private val getWalletsUseCase: GetWalletsUseCase = mockk(relaxed = true)
private val callback: SendAmountComponent.ModelCallback = mockk(relaxed = true)
private val reduceToFlow = MutableSharedFlow<BigDecimal>(extraBufferCapacity = 1)
private val reduceByFlow = MutableSharedFlow<ReduceByData>(extraBufferCapacity = 1)
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
// PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows.
clearMocks(callback, sendAmountAlertFactory, analyticsEventHandler, answers = false, recordedCalls = true, childMocks = false)
every { getUserWalletUseCase.invokeFlow(testUserWalletId) } returns flowOf(coldWallet().right())
coEvery { getMinimumTransactionAmountSyncUseCase(any(), any()) } returns BigDecimal.ONE.right()
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
every { getWalletsUseCase.invokeSync() } returns listOf(coldWallet())
every { sendAmountReduceListener.reduceToTriggerFlow } returns reduceToFlow
every { sendAmountReduceListener.reduceByTriggerFlow } returns reduceByFlow
every { sendAmountReduceListener.ignoreReduceTriggerFlow } returns emptyFlow()
every { sendAmountUpdateListener.updateAmountTriggerFlow } returns emptyFlow()
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsSendWithSwapAvailable {
@ParameterizedTest
@ProvideTestModels
fun availability(model: SwapModel) = runTest {
// Arrange
every { cryptoCurrency.isCustom } returns model.isCustom
val wallet = coldWallet(isMultiCurrency = model.isMultiCurrency)
every { getUserWalletUseCase.invokeFlow(testUserWalletId) } returns flowOf(wallet.right())
val predefined = if (model.isFromMainScreenQr) {
PredefinedValues.Content.QrCode("1", "addr", null, PredefinedValues.Source.MAIN_SCREEN)
} else {
PredefinedValues.Empty
}
// Start off an Amount route so the navigation combine stays idle until the wallet is loaded.
val currentRoute = MutableStateFlow<CommonSendRoute>(CommonSendRoute.Confirm)
val sut = buildModel(predefinedValues = predefined, currentRoute = currentRoute)
advanceUntilIdle()
// Act — flip to Amount so setSendWithSwapAvailability() re-runs with the loaded wallet
currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
advanceUntilIdle()
// Assert
assertThat(sut.isSendWithSwapAvailable.value).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
SwapModel(isCustom = false, isMultiCurrency = true, isFromMainScreenQr = false, expected = true),
SwapModel(isCustom = true, isMultiCurrency = true, isFromMainScreenQr = false, expected = false),
SwapModel(isCustom = false, isMultiCurrency = false, isFromMainScreenQr = false, expected = false),
SwapModel(isCustom = false, isMultiCurrency = true, isFromMainScreenQr = true, expected = false),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
// Looks like currentRoute.collect{} in onConvertToAnotherToken never completes, so the branch is unreachable.
@Disabled("currentRoute flow never completes — re-enable after the amount-screen rework")
inner class OnConvertToAnotherToken {
@ParameterizedTest
@ProvideTestModels
fun `WHEN onConvertToAnotherToken THEN reset-alert in edit mode else convert directly`(model: ConvertModel) =
runTest {
// Arrange
val sut = buildModel(currentRoute = MutableStateFlow(CommonSendRoute.Amount(isEditMode = model.isEditMode)))
advanceUntilIdle()
// Act
sut.onConvertToAnotherToken()
advanceUntilIdle()
// Assert
if (model.isEditMode) {
verify(exactly = 1) { sendAmountAlertFactory.showResetSendingAlert(any()) }
verify(exactly = 0) { callback.onConvertToAnotherToken(any(), any()) }
} else {
verify(exactly = 0) { sendAmountAlertFactory.showResetSendingAlert(any()) }
verify(exactly = 1) { callback.onConvertToAnotherToken(any(), any()) }
}
}
private fun provideTestModels() = listOf(
ConvertModel(isEditMode = true),
ConvertModel(isEditMode = false),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnMaxValueClick {
@ParameterizedTest
@ProvideTestModels
fun `WHEN onMaxValueClick THEN send analytics only for non-zero balance`(model: MaxClickModel) = runTest {
// Arrange
val sut = buildModel(
cryptoCurrencyStatusFlow = MutableStateFlow(loadedStatus(cryptoCurrency, balance = model.balance)),
)
advanceUntilIdle()
// Act
sut.onMaxValueClick()
// Assert
verify(exactly = model.expectedAnalyticsCalls) {
analyticsEventHandler.send(any<CommonSendAmountAnalyticEvents.MaxAmountButtonClicked>())
}
}
private fun provideTestModels() = listOf(
MaxClickModel(balance = BigDecimal.ZERO, expectedAnalyticsCalls = 0),
MaxClickModel(balance = BigDecimal.TEN, expectedAnalyticsCalls = 1),
)
}
@Nested
inner class ReduceTriggers {
@Test
fun `GIVEN reduceTo emitted WHEN handled THEN trigger fee reload`() = runTest {
// Arrange
buildModel()
advanceUntilIdle()
// Act
reduceToFlow.tryEmit(BigDecimal.ONE)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerUpdate(any()) }
}
@Test
fun `GIVEN reduceBy emitted WHEN handled THEN trigger fee reload`() = runTest {
// Arrange
buildModel()
advanceUntilIdle()
// Act
reduceByFlow.tryEmit(ReduceByData(reduceAmountBy = BigDecimal.ONE, reduceAmountByDiff = BigDecimal.ONE))
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerUpdate(any()) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnAmountNext {
@ParameterizedTest
@ProvideTestModels
fun `WHEN onAmountNext THEN send selected-currency analytics by entry type and save result`(
model: AmountNextModel,
) = runTest {
// Arrange
val sut = buildModel()
advanceUntilIdle()
sut.updateState(dataState(isFiat = model.isFiat))
// Act
sut.onAmountNext()
// Assert
verify(exactly = 1) {
analyticsEventHandler.send(
match<CommonSendAmountAnalyticEvents.SelectedCurrency> { it.type == model.expectedType },
)
}
verify(exactly = 1) { callback.onAmountResult(any(), any()) }
}
private fun provideTestModels() = listOf(
AmountNextModel(isFiat = true, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.AppCurrency),
AmountNextModel(isFiat = false, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.Token),
)
}
// region fixtures
private fun TestScope.buildModel(
predefinedValues: PredefinedValues = PredefinedValues.Empty,
currentRoute: MutableStateFlow<CommonSendRoute> = MutableStateFlow(CommonSendRoute.Amount(isEditMode = false)),
cryptoCurrencyStatusFlow: MutableStateFlow<CryptoCurrencyStatus> =
MutableStateFlow(loadedStatus(cryptoCurrency, balance = BigDecimal.TEN)),
state: AmountState = AmountState.Empty,
): SendAmountModel {
val params = SendAmountComponentParams.AmountParams(
state = state,
analyticsCategoryName = "test_send",
userWalletId = testUserWalletId,
appCurrency = AppCurrency.Default,
predefinedValues = predefinedValues,
cryptoCurrency = cryptoCurrency,
cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow,
isBalanceHidingFlow = MutableStateFlow(false),
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
accountFlow = MutableStateFlow<Account?>(null),
isAccountModeFlow = MutableStateFlow(false),
callback = callback,
currentRoute = currentRoute.filterIsInstance<AmountRoute>(),
)
return SendAmountModel(
paramsContainer = MutableParamsContainer(params),
dispatchers = testDispatcherProvider(),
getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase,
sendAmountReduceListener = sendAmountReduceListener,
sendAmountUpdateListener = sendAmountUpdateListener,
analyticsEventHandler = analyticsEventHandler,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
feeSelectorReloadTrigger = feeSelectorReloadTrigger,
getUserWalletUseCase = getUserWalletUseCase,
sendAmountAlertFactory = sendAmountAlertFactory,
getWalletsUseCase = getWalletsUseCase,
)
}
private fun coldWallet(isMultiCurrency: Boolean = true): UserWallet.Cold = mockk(relaxed = true) {
every { this@mockk.isMultiCurrency } returns isMultiCurrency
}
private fun dataState(isFiat: Boolean): AmountState.Data = mockk(relaxed = true) {
every { amountTextField.isFiatValue } returns isFiat
every { amountTextField.value } returns "1"
}
data class SwapModel(
val isCustom: Boolean,
val isMultiCurrency: Boolean,
val isFromMainScreenQr: Boolean,
val expected: Boolean,
)
data class ConvertModel(val isEditMode: Boolean)
data class MaxClickModel(val balance: BigDecimal, val expectedAnalyticsCalls: Int)
data class AmountNextModel(
val isFiat: Boolean,
val expectedType: CommonSendAmountAnalyticEvents.SelectedCurrencyType,
)
// endregion
}

View file

@ -0,0 +1,179 @@
package com.tangem.features.send.subcomponents.amount.model
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.amount.AmountRoute
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Guards the route-decoupling fix: `SendAmountModel.configAmountNavigation()` filters its route flow on
* the public `AmountRoute` interface, not the `internal CommonSendRoute.Amount`. The test feeds a
* foreign `AmountRoute` (which is NOT a `CommonSendRoute.Amount`) and asserts the navigation result is
* still produced before the fix the `combine`'s `filterIsInstance<CommonSendRoute.Amount>()` dropped
* it and `onNavigationResult` never fired, leaving an external host (e.g. staking) with a dead Next
* button. Also checks the `isEditMode` back-icon / primary-button mapping is unaffected.
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class SendAmountNavigationTest {
private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase = mockk()
private val sendAmountReduceListener: SendAmountReduceListener = mockk()
private val sendAmountUpdateListener: SendAmountUpdateListener = mockk()
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true)
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
private val sendAmountAlertFactory: SendAmountAlertFactory = mockk(relaxed = true)
private val getWalletsUseCase: GetWalletsUseCase = mockk(relaxed = true)
private val callback: SendAmountComponent.ModelCallback = mockk(relaxed = true)
private val cryptoCurrency = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum)
private var model: SendAmountModel? = null
@BeforeEach
fun setup() {
clearMocks(
getMinimumTransactionAmountSyncUseCase,
sendAmountReduceListener,
sendAmountUpdateListener,
getSelectedAppCurrencyUseCase,
getUserWalletUseCase,
callback,
)
// No wallet → the model stays on AmountState.Empty (the heavy AmountStateConverter path is skipped),
// which is all the navigation block needs to emit.
every { getUserWalletUseCase.invokeFlow(any()) } returns emptyFlow()
every { sendAmountReduceListener.reduceToTriggerFlow } returns emptyFlow()
every { sendAmountReduceListener.reduceByTriggerFlow } returns emptyFlow()
every { sendAmountReduceListener.ignoreReduceTriggerFlow } returns emptyFlow()
every { sendAmountUpdateListener.updateAmountTriggerFlow } returns emptyFlow()
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
coEvery { getMinimumTransactionAmountSyncUseCase(any(), any()) } returns BigDecimal.ZERO.right()
}
@AfterEach
fun tearDown() {
// Cancel modelScope so the long-lived navigation/status collectors stop between tests.
model?.onDestroy()
model = null
}
@Test
fun `GIVEN a foreign AmountRoute WHEN model created THEN navigation produced with close icon and next button`() =
runTest {
// Arrange — a route that is NOT CommonSendRoute.Amount (the impl type the model used to filter on).
val navSlot = slot<NavigationUM>()
// Act
createModel(testScope = this, route = TestAmountRoute(isEditMode = false))
advanceUntilIdle()
// Assert — before the fix this never fired for a non-CommonSendRoute.Amount route.
verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) }
val content = navSlot.captured as NavigationUM.Content
assertThat(content.backIconRes).isEqualTo(R.drawable.ic_close_24)
assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_next))
}
@Test
fun `GIVEN a foreign AmountRoute in edit mode WHEN model created THEN navigation has back icon and continue button`() =
runTest {
// Arrange
val navSlot = slot<NavigationUM>()
// Act
createModel(testScope = this, route = TestAmountRoute(isEditMode = true))
advanceUntilIdle()
// Assert
verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) }
val content = navSlot.captured as NavigationUM.Content
assertThat(content.backIconRes).isEqualTo(R.drawable.ic_back_24)
assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_continue))
}
private fun createModel(testScope: TestScope, route: AmountRoute): SendAmountModel {
val params = SendAmountComponentParams.AmountParams(
state = AmountState.Empty,
analyticsCategoryName = "test",
userWalletId = UserWalletId(stringValue = "0123456789"),
appCurrency = AppCurrency.Default,
predefinedValues = PredefinedValues.Empty,
cryptoCurrency = cryptoCurrency,
cryptoCurrencyStatusFlow = MutableStateFlow(mockk<CryptoCurrencyStatus>(relaxed = true)),
isBalanceHidingFlow = MutableStateFlow(false),
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
accountFlow = MutableStateFlow<Account?>(null),
isAccountModeFlow = MutableStateFlow(false),
callback = callback,
currentRoute = MutableStateFlow(route),
)
return SendAmountModel(
paramsContainer = MutableParamsContainer(value = params),
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase,
sendAmountReduceListener = sendAmountReduceListener,
sendAmountUpdateListener = sendAmountUpdateListener,
analyticsEventHandler = analyticsEventHandler,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
feeSelectorReloadTrigger = feeSelectorReloadTrigger,
getUserWalletUseCase = getUserWalletUseCase,
sendAmountAlertFactory = sendAmountAlertFactory,
getWalletsUseCase = getWalletsUseCase,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private data class TestAmountRoute(override val isEditMode: Boolean) : AmountRoute
}

View file

@ -0,0 +1,551 @@
package com.tangem.features.send.subcomponents.destination.model
import arrow.core.left
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.CryptoCurrencyAddress
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.send.api.entity.PredefinedValues
import kotlinx.collections.immutable.toImmutableList
import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.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.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.testDispatcherProvider
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import com.tangem.test.core.ProvideTestModels
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
@OptIn(ExperimentalCoroutinesApi::class)
internal class SendDestinationModelTest {
private val testUserWalletId = UserWalletId("1234567890ABCDEF")
private val networkRawId = "eth"
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true)
private val contactIcon: AccountIconUM.CryptoPortfolio = mockk(relaxed = true)
private val router: Router = mockk(relaxed = true)
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase = mockk(relaxed = true)
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase = mockk(relaxed = true)
private val isMemoRequiredUseCase: IsMemoRequiredUseCase = mockk(relaxed = true)
private val getWalletsUseCase: GetWalletsUseCase = mockk(relaxed = true)
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase = mockk(relaxed = true)
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase = mockk(relaxed = true)
private val isSelfSendAvailableUseCase: IsSelfSendAvailableUseCase = mockk(relaxed = true)
private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true)
private val parseQrCodeUseCase: ParseQrCodeUseCase = mockk(relaxed = true)
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk(relaxed = true)
private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase =
mockk(relaxed = true)
private val sendDestinationAlertFactory: SendDestinationAlertFactory = mockk(relaxed = true)
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
private val getContactsUseCase: GetContactsUseCase = mockk(relaxed = true)
private val contactSelectionListener: ContactSelectionListener = mockk(relaxed = true)
private val callback: SendDestinationComponent.ModelCallback = mockk(relaxed = true)
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
// PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows.
clearMocks(callback, validateWalletAddressUseCase, answers = false, recordedCalls = true, childMocks = false)
coEvery { getNetworkAddressesUseCase.invokeSync(any(), any<Network.RawID>()) } returns emptyList()
every { getWalletsUseCase() } returns flowOf(emptyList())
every { multiAccountStatusListSupplier() } returns flowOf(emptyList())
every { getFixedTxHistoryItemsUseCase(any(), any(), any()) } returns flowOf(emptyList<TxInfo>()).right()
every { isAccountsModeEnabledUseCase() } returns flowOf(false)
coEvery { isSelfSendAvailableUseCase.invokeSync(any(), any()) } returns false
every { listenToQrScanningUseCase(any()) } returns emptyFlow<String>().right()
coEvery { validateWalletMemoUseCase(any(), any(), any()) } returns Unit.right()
coEvery { isMemoRequiredUseCase(any(), any()) } returns false
every { getContactsUseCase(any(), any()) } returns flowOf(emptyList())
every { contactSelectionListener.resultFlow } returns MutableSharedFlow()
coEvery { getBackupProblematicWalletForAddressUseCase(any()) } returns null
every { cryptoCurrency.network.rawId } returns networkRawId
}
@Nested
inner class Validate {
@Test
fun `GIVEN valid non-problematic address WHEN address entered THEN send valid analytics without backup alert`() =
runTest {
// Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns
AddressValidation.Success.Valid.right()
val sut = buildModel()
advanceUntilIdle()
// Act
sut.onRecipientAddressValueChange("validAddr", EnterAddressSource.InputField)
advanceUntilIdle()
// Assert
verify(exactly = 1) {
analyticsEventHandler.send(
match<SendDestinationAnalyticEvents.AddressEntered> { it.isValid },
)
}
verify(exactly = 0) { sendDestinationAlertFactory.showRecipientBackupErrorAlert(any()) }
// InputField is not an auto-next source → no auto-advance even for a valid address
verify(exactly = 0) { callback.onNextClick() }
}
@Test
fun `GIVEN valid backup-problematic address WHEN address entered THEN show recipient backup error alert`() =
runTest {
// Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns
AddressValidation.Success.Valid.right()
coEvery { getBackupProblematicWalletForAddressUseCase(any()) } returns testUserWalletId
val sut = buildModel()
advanceUntilIdle()
// Act
sut.onRecipientAddressValueChange("problematicAddr", EnterAddressSource.InputField)
advanceUntilIdle()
// Assert
verify(exactly = 1) { sendDestinationAlertFactory.showRecipientBackupErrorAlert(any()) }
// backup override flips the (format-valid) result to error → analytics reports it as invalid
verify(exactly = 1) {
analyticsEventHandler.send(match<SendDestinationAnalyticEvents.AddressEntered> { !it.isValid })
}
}
@Test
fun `GIVEN invalid address WHEN address entered THEN send invalid analytics`() = runTest {
// Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns
AddressValidation.Error.InvalidAddress.left()
val sut = buildModel()
advanceUntilIdle()
// Act
sut.onRecipientAddressValueChange("badAddr", EnterAddressSource.InputField)
advanceUntilIdle()
// Assert
verify(exactly = 1) {
analyticsEventHandler.send(
match<SendDestinationAnalyticEvents.AddressEntered> { !it.isValid },
)
}
}
@Test
fun `GIVEN memo change with null type WHEN handled THEN no address-entered analytics and no auto-next`() =
runTest {
// Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns
AddressValidation.Success.Valid.right()
val sut = buildModel()
advanceUntilIdle()
// Act — onRecipientMemoValueChange calls validate(type = null)
sut.onRecipientMemoValueChange("memo", isValuePasted = false)
advanceUntilIdle()
// Assert
verify(exactly = 0) {
analyticsEventHandler.send(any<SendDestinationAnalyticEvents.AddressEntered>())
}
verify(exactly = 0) { callback.onNextClick() }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class AutoNext {
@ParameterizedTest
@ProvideTestModels
fun `GIVEN auto-next source WHEN address entered THEN advance only when address valid`(model: AutoNextModel) =
runTest {
// Arrange
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns model.addressValidation
val sut = buildModel()
advanceUntilIdle()
// Act — RecentAddress is an auto-next source
sut.onRecipientAddressValueChange("addr", EnterAddressSource.RecentAddress)
advanceUntilIdle()
// Assert
verify(exactly = model.expectedNextClicks) { callback.onNextClick() }
}
private fun provideTestModels() = listOf(
AutoNextModel(addressValidation = AddressValidation.Success.Valid.right(), expectedNextClicks = 1),
AutoNextModel(addressValidation = AddressValidation.Error.InvalidAddress.left(), expectedNextClicks = 0),
)
}
@Nested
inner class QrScan {
@Test
fun `GIVEN unparseable QR WHEN scanned THEN do NOT validate`() = runTest {
// Arrange
val qrFlow = MutableStateFlow("rawQr")
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns
IllegalStateException("bad qr").left()
buildModel()
// Act
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) }
}
}
@Nested
inner class Contacts {
@Test
fun `GIVEN a selected contact WHEN applySelectedContact THEN address filled validated and contact set`() =
runTest {
// Arrange
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
val sut = buildModel()
advanceUntilIdle()
// Act
sut.applySelectedContact(selectedContact(name = "Bob", address = "0xBob"))
advanceUntilIdle()
// Assert — the contact's address is filled in and validated, and the contact name is shown
coVerify {
validateWalletAddressUseCase(any(), any(), eq("0xBob"), any<List<CryptoCurrencyAddress>>(), any())
}
assertThat(content(sut).addressTextField.value).isEqualTo("0xBob")
assertThat(content(sut).addressTextField.contactName).isEqualTo("Bob")
}
@Test
fun `GIVEN a contact is set WHEN route switches to edit mode THEN the contact is reset`() = runTest {
// Arrange
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
val currentRoute = MutableStateFlow<DestinationRoute>(CommonSendRoute.Destination(isEditMode = false))
val sut = buildModel(currentRoute = currentRoute)
advanceUntilIdle()
sut.applySelectedContact(selectedContact(name = "Dave", address = "0xDave"))
advanceUntilIdle()
assertThat(content(sut).addressTextField.contactName).isEqualTo("Dave")
// Act — entering edit mode must clear the bound contact
currentRoute.value = CommonSendRoute.Destination(isEditMode = true)
advanceUntilIdle()
// Assert
assertThat(content(sut).addressTextField.contactName).isNull()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ContactRecognition {
@ParameterizedTest
@ProvideTestModels
fun `WHEN address entered THEN recognize matching saved contact case-insensitively`(
model: ContactRecognitionModel,
) = runTest {
// Arrange
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
every { getContactsUseCase(any(), any()) } returns
flowOf(listOf(buildContact(name = model.savedName, address = model.savedAddress)))
val sut = buildModel()
advanceUntilIdle()
// Act
sut.onRecipientAddressValueChange(model.enteredAddress, EnterAddressSource.InputField)
advanceUntilIdle()
// Assert
assertThat(content(sut).addressTextField.contactName).isEqualTo(model.expectedContactName)
}
private fun provideTestModels() = listOf(
// saved "0xAddr", entered "0xaddr" → case-insensitive match
ContactRecognitionModel(savedName = "Alice", savedAddress = "0xAddr", enteredAddress = "0xaddr", expectedContactName = "Alice"),
// entered address not among saved contacts → no recognition
ContactRecognitionModel(savedName = "Alice", savedAddress = "0xOther", enteredAddress = "0xAddr", expectedContactName = null),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnContactClick {
@ParameterizedTest
@ProvideTestModels
fun `WHEN onContactClick THEN apply single-address contact directly else open selector`(
model: ContactClickModel,
) = runTest {
// Arrange
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
val sut = buildModel()
advanceUntilIdle()
// Act
sut.onContactClick(matchedContact(addresses = model.addresses))
advanceUntilIdle()
// Assert
if (model.expectedValidatedAddress != null) {
// single entry → applied directly → that address gets validated
coVerify {
validateWalletAddressUseCase(
any(), any(), eq(model.expectedValidatedAddress), any<List<CryptoCurrencyAddress>>(), any(),
)
}
} else {
// multiple entries → selector opened, nothing applied/validated yet
coVerify(exactly = 0) {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
}
}
}
private fun provideTestModels() = listOf(
ContactClickModel(addresses = listOf("0xSingle"), expectedValidatedAddress = "0xSingle"),
ContactClickModel(addresses = listOf("0xA", "0xB"), expectedValidatedAddress = null),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ShowAddContact {
@ParameterizedTest
@ProvideTestModels
fun `WHEN address entered THEN show add-contact only when available and not already saved`(
model: AddContactModel,
) = runTest {
// Arrange
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
every { getContactsUseCase(any(), any()) } returns
flowOf(model.savedAddresses.map { buildContact(address = it) })
val sut = buildBlockModel(isAddContactAvailable = model.isAddContactAvailable)
advanceUntilIdle()
// Act
sut.onRecipientAddressValueChange(model.enteredAddress, EnterAddressSource.InputField)
advanceUntilIdle()
// Assert
assertThat(sut.showAddContact.value).isEqualTo(model.expectedShown)
}
private fun provideTestModels() = listOf(
// not available -> never shown, even for a fresh valid address
AddContactModel(isAddContactAvailable = false, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = false),
// available + address not in the book -> shown
AddContactModel(isAddContactAvailable = true, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = true),
// available but address already saved -> hidden
AddContactModel(isAddContactAvailable = true, savedAddresses = listOf("0xSaved"), enteredAddress = "0xSaved", expectedShown = false),
)
}
// region fixtures
private fun TestScope.buildModel(
currentRoute: MutableStateFlow<DestinationRoute> =
MutableStateFlow(CommonSendRoute.Destination(isEditMode = false)),
): SendDestinationModel {
val params = SendDestinationComponentParams.DestinationParams(
state = DestinationUM.Empty(),
analyticsCategoryName = "test_send",
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
cryptoCurrency = cryptoCurrency,
userWalletId = testUserWalletId,
title = stringReference("Send to"),
isBalanceHidingFlow = MutableStateFlow(false),
currentRoute = currentRoute,
callback = callback,
isAllowSelfSend = false,
)
return createModel(params)
}
/** Builds the model with the success-screen block flavor ([DestinationBlockParams]) used by `showAddContact`. */
private fun TestScope.buildBlockModel(isAddContactAvailable: Boolean): SendDestinationModel {
val params = SendDestinationComponentParams.DestinationBlockParams(
state = DestinationUM.Empty(),
analyticsCategoryName = "test_send",
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
userWalletId = testUserWalletId,
cryptoCurrency = cryptoCurrency,
blockClickEnableFlow = MutableStateFlow(true),
predefinedValues = PredefinedValues.Empty,
isAllowSelfSend = false,
isAddContactAvailable = isAddContactAvailable,
)
return createModel(params)
}
private fun TestScope.createModel(params: SendDestinationComponentParams): SendDestinationModel {
return SendDestinationModel(
paramsContainer = MutableParamsContainer(params),
dispatchers = testDispatcherProvider(),
router = router,
validateWalletAddressUseCase = validateWalletAddressUseCase,
validateWalletMemoUseCase = validateWalletMemoUseCase,
isMemoRequiredUseCase = isMemoRequiredUseCase,
getWalletsUseCase = getWalletsUseCase,
getNetworkAddressesUseCase = getNetworkAddressesUseCase,
getFixedTxHistoryItemsUseCase = getFixedTxHistoryItemsUseCase,
isSelfSendAvailableUseCase = isSelfSendAvailableUseCase,
listenToQrScanningUseCase = listenToQrScanningUseCase,
parseQrCodeUseCase = parseQrCodeUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
analyticsEventHandler = analyticsEventHandler,
multiAccountStatusListSupplier = multiAccountStatusListSupplier,
getBackupProblematicWalletForAddressUseCase = getBackupProblematicWalletForAddressUseCase,
sendDestinationAlertFactory = sendDestinationAlertFactory,
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
getContactsUseCase = getContactsUseCase,
contactSelectionListener = contactSelectionListener,
)
}
private fun buildContact(name: String = "Alice", address: String = "0xAddr"): Contact = Contact(
id = ContactId("c1"),
walletId = testUserWalletId,
name = ContactName(name).getOrNull()!!,
icon = "icon",
iconColor = "#FFFFFF",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addresses = listOf(
AddressEntry(
id = AddressEntryId("e1"),
address = address,
networkId = Network.RawID(networkRawId),
networkName = "Ethereum",
memo = null,
signature = "",
),
),
)
private fun matchedContact(name: String = "Alice", addresses: List<String> = listOf("0xAddr")): MatchedContact =
MatchedContact(
contactId = "c1",
walletId = testUserWalletId.stringValue,
name = name,
icon = contactIcon,
networkId = networkRawId,
entries = addresses
.map { MatchedContact.ContactAddress(address = it, memo = null, networkName = "Ethereum") }
.toImmutableList(),
)
private fun selectedContact(
name: String = "Alice",
address: String = "0xAddr",
memo: String? = null,
): SelectedContact = SelectedContact(
contactId = "c1",
name = name,
icon = contactIcon,
address = address,
networkId = networkRawId,
memo = memo,
)
private fun content(model: SendDestinationModel): DestinationUM.Content =
model.uiState.value as DestinationUM.Content
data class AutoNextModel(val addressValidation: AddressValidationResult, val expectedNextClicks: Int)
data class AddContactModel(
val isAddContactAvailable: Boolean,
val savedAddresses: List<String>,
val enteredAddress: String,
val expectedShown: Boolean,
)
data class ContactClickModel(val addresses: List<String>, val expectedValidatedAddress: String?)
data class ContactRecognitionModel(
val savedName: String,
val savedAddress: String,
val enteredAddress: String,
val expectedContactName: String?,
)
// endregion
}

View file

@ -0,0 +1,134 @@
package com.tangem.features.send.subcomponents.destination.model.converters
import android.text.format.DateFormat
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.network.TxInfo
import com.tangem.features.send.impl.R
import com.tangem.features.send.subcomponents.destination.model.transformers.RECENT_DEFAULT_COUNT
import com.tangem.features.send.subcomponents.destination.model.transformers.RECENT_KEY_TAG
import com.tangem.features.send.subcomponents.destination.model.transformers.emptyListState
import com.tangem.test.core.ProvideTestModels
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SendRecipientHistoryListConverterTest {
private val cryptoCurrency = MockCryptoCurrencyFactory().ethereum
private val converter = SendRecipientHistoryListConverter(cryptoCurrency)
@BeforeEach
fun setUp() {
// Mapping formats the timestamp via DateTimeFormatters -> DateFormat.getBestDateTimePattern,
// which is an Android stub on the JVM. Mirror the project pattern so convert() runs.
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
}
@AfterEach
fun tearDown() {
unmockkStatic(DateFormat::class)
}
private fun txInfo(
isOutgoing: Boolean = true,
type: TxInfo.TransactionType = TxInfo.TransactionType.Transfer,
interactionAddressType: TxInfo.InteractionAddressType? = TxInfo.InteractionAddressType.User(RECIPIENT),
destinationType: TxInfo.DestinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(RECIPIENT)),
sourceType: TxInfo.SourceType = TxInfo.SourceType.Single(SOURCE),
amount: BigDecimal = BigDecimal.ONE,
txHash: String = "hash",
) = TxInfo(
txHash = txHash,
timestampInMillis = 1_700_000_000_000L,
isOutgoing = isOutgoing,
destinationType = destinationType,
sourceType = sourceType,
interactionAddressType = interactionAddressType,
status = TxInfo.TransactionStatus.Confirmed,
type = type,
amount = amount,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Filtering {
@ParameterizedTest
@ProvideTestModels
fun `GIVEN excluded transaction WHEN convert THEN filtered out leaving empty placeholder`(model: FilterModel) {
// Act
val actual = converter.convert(listOf(model.tx))
// Assert
assertThat(actual).isEqualTo(emptyListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT))
}
private fun provideTestModels() = listOf(
FilterModel("non-transfer type", txInfo(type = TxInfo.TransactionType.Swap)),
FilterModel(
"contract interaction",
txInfo(interactionAddressType = TxInfo.InteractionAddressType.Contract(RECIPIENT)),
),
FilterModel("null interaction", txInfo(interactionAddressType = null)),
FilterModel("incoming", txInfo(isOutgoing = false)),
FilterModel(
"multiple destinations",
txInfo(destinationType = TxInfo.DestinationType.Multiple(listOf(TxInfo.AddressType.User(RECIPIENT)))),
),
FilterModel("zero amount", txInfo(amount = BigDecimal.ZERO)),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Mapping {
@Test
fun `GIVEN valid outgoing transfer WHEN convert THEN mapped to recipient item`() {
// Act
val actual = converter.convert(listOf(txInfo()))
// Assert
assertThat(actual).hasSize(1)
val item = actual.first()
assertThat(item.id).isEqualTo("${RECENT_KEY_TAG}0")
assertThat(item.title).isEqualTo(stringReference(RECIPIENT))
assertThat(item.subtitleEndOffset).isEqualTo(cryptoCurrency.symbol.length)
assertThat(item.subtitleIconRes).isEqualTo(R.drawable.ic_arrow_up_24)
assertThat(item.isVisible).isTrue()
}
@Test
fun `GIVEN more than ten valid transactions WHEN convert THEN capped at ten`() {
// Arrange
val txs = (1..12).map { txInfo(txHash = "hash$it") }
// Act
val actual = converter.convert(txs)
// Assert
assertThat(actual).hasSize(10)
assertThat(actual.first().id).isEqualTo("${RECENT_KEY_TAG}0")
assertThat(actual.last().id).isEqualTo("${RECENT_KEY_TAG}9")
}
}
data class FilterModel(val case: String, val tx: TxInfo)
private companion object {
private const val RECIPIENT = "0xRecipientAddress"
private const val SOURCE = "0xSourceAddress"
}
}

View file

@ -0,0 +1,130 @@
package com.tangem.features.send.subcomponents.destination.model.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.send.subcomponents.destination.model.transformers.WALLET_DEFAULT_COUNT
import com.tangem.features.send.subcomponents.destination.model.transformers.WALLET_KEY_TAG
import com.tangem.features.send.subcomponents.destination.model.transformers.emptyListState
import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Nested
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 SendRecipientWalletListConverterTest {
private val currencyFactory = MockCryptoCurrencyFactory()
private val coin: CryptoCurrency = currencyFactory.ethereum
private val token: CryptoCurrency = currencyFactory.createToken(Blockchain.Ethereum)
private fun converter(
senderAddress: String? = SENDER,
isSelfSendAvailable: Boolean = false,
isAccountsMode: Boolean = false,
) = SendRecipientWalletListConverter(
senderAddress = senderAddress,
isSelfSendAvailable = isSelfSendAvailable,
isAccountsMode = isAccountsMode,
)
private fun wallet(
name: String = "Wallet",
userWalletId: UserWalletId = UserWalletId("a1"),
address: String = "0xWalletAddress",
cryptoCurrency: CryptoCurrency = coin,
) = DestinationWalletUM(
name = name,
userWalletId = userWalletId,
address = address,
cryptoCurrency = cryptoCurrency,
account = null,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Filtering {
@ParameterizedTest
@ProvideTestModels
fun `GIVEN excluded wallet WHEN convert THEN filtered out leaving empty placeholder`(model: ExcludedModel) {
// Act
val actual = model.converter.convert(listOf(model.wallet))
// Assert
assertThat(actual).isEqualTo(emptyListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT))
}
private fun provideTestModels() = listOf(
ExcludedModel("blank address", wallet(address = ""), converter()),
ExcludedModel("token and not a payment account", wallet(cryptoCurrency = token), converter()),
ExcludedModel(
"own address while self-send disabled",
wallet(address = SENDER),
converter(senderAddress = SENDER, isSelfSendAvailable = false),
),
)
@Test
fun `GIVEN own address while self-send enabled WHEN convert THEN included`() {
// Act
val actual = converter(senderAddress = SENDER, isSelfSendAvailable = true)
.convert(listOf(wallet(address = SENDER)))
// Assert
assertThat(actual).hasSize(1)
assertThat(actual.first().title).isEqualTo(stringReference(SENDER))
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Grouping {
@Test
fun `GIVEN same name across multiple wallets WHEN convert THEN names disambiguated with index`() {
// Arrange (same name, different userWalletId -> group size > 1)
val wallets = listOf(
wallet(name = "Main", userWalletId = UserWalletId("a1"), address = "0xA"),
wallet(name = "Main", userWalletId = UserWalletId("a2"), address = "0xB"),
)
// Act
val actual = converter().convert(wallets)
// Assert
assertThat(actual).hasSize(2)
assertThat(actual[0].id).isEqualTo("${WALLET_KEY_TAG}0")
assertThat(actual[1].id).isEqualTo("${WALLET_KEY_TAG}1")
assertThat(actual[0].subtitle).isEqualTo(stringReference("Main 1"))
assertThat(actual[1].subtitle).isEqualTo(stringReference("Main 2"))
assertThat(actual[0].title).isEqualTo(stringReference("0xA"))
assertThat(actual[1].title).isEqualTo(stringReference("0xB"))
}
@Test
fun `GIVEN single wallet for a name WHEN convert THEN name kept without index`() {
// Act
val actual = converter().convert(listOf(wallet(name = "Solo", address = "0xA")))
// Assert
assertThat(actual).hasSize(1)
assertThat(actual.first().subtitle).isEqualTo(stringReference("Solo"))
}
}
data class ExcludedModel(
val case: String,
val wallet: DestinationWalletUM,
val converter: SendRecipientWalletListConverter,
)
private companion object {
private const val SENDER = "0xSenderAddress"
}
}

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