Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-21 18:34:38 +03:00
commit 438126dc82
665 changed files with 16174 additions and 6245 deletions

View file

@ -8,4 +8,6 @@ internal class DefaultSendFeatureToggles(
) : SendFeatureToggles {
override val isSendRedesignEnabled: Boolean
get() = featureToggles.isFeatureEnabled("SEND_REDESIGN_ENABLED")
override val isSendWithSwapEnabled: Boolean
get() = featureToggles.isFeatureEnabled("SEND_VIA_SWAP_ENABLED")
}

View file

@ -18,6 +18,11 @@ internal sealed class CommonSendRoute : Route {
override val isEditMode: Boolean = true
}
@Serializable
data object ConfirmSuccess : CommonSendRoute() {
override val isEditMode: Boolean = false
}
@Serializable
data class Destination(
override val isEditMode: Boolean,

View file

@ -6,8 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.stack.animation.*
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
@ -15,6 +14,8 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.send.confirm.SendConfirmComponent
import com.tangem.features.send.v2.send.success.SendConfirmSuccessComponent
@Composable
internal fun SendContent(
@ -22,22 +23,30 @@ internal fun SendContent(
stackState: ChildStack<CommonSendRoute, ComposableContentComponent>,
) {
Column(
modifier = Modifier.Companion
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.Companion.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally,
) {
SendAppBar(navigationUM = navigationUM)
Children(
stack = stackState,
animation = stackAnimation(slide()),
animation = stackAnimation { child ->
when (child.instance) {
is SendConfirmSuccessComponent -> fade(minAlpha = 1.0f)
is SendConfirmComponent -> fade()
else -> slide()
}
},
modifier = Modifier.weight(1f),
) {
it.instance.Content(Modifier.weight(1f))
}
SendNavigationButtons(navigationUM = navigationUM)
if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) {
SendNavigationButtons(navigationUM = navigationUM)
}
}
}

View file

@ -2,10 +2,8 @@ package com.tangem.features.send.v2.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.send.v2.DefaultSendFeatureToggles
import com.tangem.features.send.v2.api.NFTSendComponent
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.*
import com.tangem.features.send.v2.entrypoint.DefaultSendEntryPointComponent
import com.tangem.features.send.v2.send.DefaultSendComponent
import com.tangem.features.send.v2.sendnft.DefaultNFTSendComponent
import com.tangem.features.send.v2.subcomponents.notifications.DefaultSendNotificationsComponent
@ -43,4 +41,10 @@ internal interface SendFeatureModuleBinds {
fun provideNotificationComponentFactory(
impl: DefaultSendNotificationsComponent.Factory,
): SendNotificationsComponent.Factory
@Binds
@Singleton
fun provideSendEntryPointComponentFactory(
impl: DefaultSendEntryPointComponent.Factory,
): SendEntryPointComponent.Factory
}

View file

@ -0,0 +1,71 @@
package com.tangem.features.send.v2.entrypoint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendEntryPointComponent
import com.tangem.features.send.v2.entrypoint.model.SendEntryPoint
import com.tangem.features.send.v2.entrypoint.model.SendEntryPointModel
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultSendEntryPointComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendEntryPointComponent.Params,
sendWithSwapComponentFactory: SendWithSwapComponent.Factory,
sendComponentFactory: SendComponent.Factory,
) : SendEntryPointComponent, AppComponentContext by appComponentContext {
private val model: SendEntryPointModel = getOrCreateModel(params = params)
private val sendWithSwapComponent = sendWithSwapComponentFactory.create(
context = child("sendEntrySendWithSwap"),
params = SendWithSwapComponent.Params(
userWalletId = params.userWalletId,
currency = params.cryptoCurrency,
callback = model,
),
)
private val sendComponent = sendComponentFactory.create(
context = child("sendEntryVanillaSend"),
params = SendComponent.Params(
userWalletId = params.userWalletId,
currency = params.cryptoCurrency,
callback = model,
),
)
@Composable
override fun Content(modifier: Modifier) {
val sendEntryState by model.sendEntryPointState.collectAsStateWithLifecycle()
sendComponent.Content(modifier)
AnimatedVisibility(
visible = sendEntryState == SendEntryPoint.SendWithSwap,
enter = fadeIn(),
exit = fadeOut(),
) {
sendWithSwapComponent.Content(modifier)
}
}
@AssistedFactory
interface Factory : SendEntryPointComponent.Factory {
override fun create(
context: AppComponentContext,
params: SendEntryPointComponent.Params,
): DefaultSendEntryPointComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.entrypoint.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.send.v2.entrypoint.model.SendEntryPointModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface SendEntryPointModule {
@Binds
@IntoMap
@ClassKey(SendEntryPointModel::class)
fun provideSendEntryPointModel(model: SendEntryPointModel): Model
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.send.v2.entrypoint.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendEntryPointComponent
import com.tangem.features.send.v2.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkListener
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import jakarta.inject.Inject
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ModelScoped
internal class SendEntryPointModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
val appRouter: AppRouter,
private val swapChooseTokenNetworkListener: SwapChooseTokenNetworkListener,
private val sendAmountUpdateTrigger: SendAmountUpdateTrigger,
private val swapAmountUpdateTrigger: SwapAmountUpdateTrigger,
) : Model(), SendComponent.ModelCallback, SendWithSwapComponent.ModelCallback {
private val params: SendEntryPointComponent.Params = paramsContainer.require()
val sendEntryPointState: StateFlow<SendEntryPoint>
field = MutableStateFlow(SendEntryPoint.SendVanilla)
private var swapChooseTokenListenerJobHolder = JobHolder()
override fun onConvertToAnotherToken(lastAmount: String) {
appRouter.push(
AppRoute.ChooseManagedTokens(
userWalletId = params.userWalletId,
initialCurrency = params.cryptoCurrency,
source = AppRoute.ChooseManagedTokens.Source.SendViaSwap,
),
)
observeChooseSelectToken(lastAmount)
}
override fun onCloseSwap(lastAmount: String) {
modelScope.launch {
if (lastAmount.isNotBlank()) {
sendAmountUpdateTrigger.triggerUpdateAmount(lastAmount)
}
triggerScreenUpdate(SendEntryPoint.SendVanilla)
}
}
private fun observeChooseSelectToken(lastAmount: String) {
swapChooseTokenNetworkListener.swapChooseTokenNetworkResultFlow
.onEach { currency ->
if (lastAmount.isNotBlank()) {
swapAmountUpdateTrigger.triggerUpdateAmount(lastAmount)
}
triggerScreenUpdate(SendEntryPoint.SendWithSwap)
}
.launchIn(modelScope)
.saveIn(swapChooseTokenListenerJobHolder)
}
private fun triggerScreenUpdate(entry: SendEntryPoint) {
swapChooseTokenListenerJobHolder.cancel()
sendEntryPointState.update { entry }
}
}
enum class SendEntryPoint {
SendVanilla,
SendWithSwap,
}

View file

@ -1,62 +1,66 @@
package com.tangem.features.send.v2.feeselector
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.activate
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.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN
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.core.ui.res.TangemThemePreview
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.core.ui.extensions.conditional
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.FeeSelectorComponent
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.feeselector.ui.FeeSelectorBlockContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.math.BigDecimal
import kotlinx.serialization.builtins.serializer
internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: FeeSelectorParams.FeeSelectorBlockParams,
@Assisted private val params: FeeSelectorParams.FeeSelectorBlockParams,
@Assisted onResult: (feeSelectorUM: FeeSelectorUM) -> Unit,
private val feeSelectorComponentFactory: FeeSelectorComponent.Factory,
) : FeeSelectorBlockComponent, AppComponentContext by appComponentContext {
private val model: FeeSelectorModel = getOrCreateModel(params = params)
private val bottomSheetSlot = childSlot(
source = model.feeSelectorBottomSheet,
serializer = Unit.serializer(),
handleBackButton = false,
childFactory = { _, componentContext ->
feeSelectorComponentFactory.create(
context = childByContext(componentContext),
params = FeeSelectorParams.FeeSelectorDetailsParams(
state = model.uiState.value,
onLoadFee = params.onLoadFee,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
callback = model,
suggestedFeeState = FeeSelectorParams.SuggestedFeeState.None,
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
),
onDismiss = {
model.feeSelectorBottomSheet.dismiss()
},
)
},
)
init {
model.uiState
.onEach(params.callback::onFeeResult)
.onEach(onResult)
.launchIn(componentScope)
}
@ -67,7 +71,18 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
FeeSelectorBlockContent(modifier = modifier, state = state)
val bottomSheet by bottomSheetSlot.subscribeAsState()
FeeSelectorBlockContent(
state = state,
modifier = modifier
.conditional(params.feeDisplaySource == FeeSelectorParams.FeeDisplaySource.Screen) {
Modifier.clickable {
model.feeSelectorBottomSheet.activate(Unit)
}
},
)
bottomSheet.child?.instance?.BottomSheet()
}
@AssistedFactory
@ -75,121 +90,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
override fun create(
context: AppComponentContext,
params: FeeSelectorParams.FeeSelectorBlockParams,
onResult: (feeSelectorUM: FeeSelectorUM) -> Unit,
): DefaultFeeSelectorBlockComponent
}
}
@Composable
private fun FeeSelectorBlockContent(state: FeeSelectorUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(TangemTheme.colors.background.action)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.ic_fee_new_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
Text(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
text = stringResourceSafe(R.string.common_network_fee_title),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
Icon(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing6)
.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_token_info_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
SpacerWMax()
when (state) {
is FeeSelectorUM.Content -> FeeContent(state)
is FeeSelectorUM.Loading -> FeeLoading()
is FeeSelectorUM.Error -> FeeError()
}
}
}
@Composable
private fun RowScope.FeeError() {
Text(
text = EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun RowScope.FeeLoading() {
TextShimmer(
radius = TangemTheme.dimens.radius3,
style = TangemTheme.typography.body1,
modifier = Modifier.width(width = TangemTheme.dimens.size90),
)
}
@Composable
private fun RowScope.FeeContent(state: FeeSelectorUM.Content) {
val fiatRate = state.feeFiatRateUM
EllipsisText(
text = if (fiatRate != null) {
getFiatString(
value = state.selectedFeeItem.fee.amount.value,
rate = fiatRate.rate,
appCurrency = fiatRate.appCurrency,
approximate = state.isFeeApproximate,
)
} else {
state.selectedFeeItem.fee.amount.value.format {
crypto(
symbol = state.selectedFeeItem.fee.amount.currencySymbol,
decimals = state.selectedFeeItem.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
}
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.End,
modifier = Modifier
.weight(1f)
.padding(start = TangemTheme.dimens.spacing4),
)
Icon(
modifier = Modifier.size(width = 18.dp, height = 24.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeSelectorBlockContent_Preview() {
TangemThemePreview {
val feeItem = FeeItem.Market(
Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum)),
)
FeeSelectorBlockContent(
modifier = Modifier.fillMaxWidth(),
state = FeeSelectorUM.Content(
feeItems = persistentListOf(feeItem),
selectedFeeItem = feeItem,
isFeeApproximate = false,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500"),
appCurrency = AppCurrency.Default,
),
displayNonceInput = false,
nonce = null,
onNonceChange = {},
),
)
}
}

View file

@ -16,18 +16,24 @@ import dagger.assisted.AssistedInject
internal class DefaultFeeSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: FeeSelectorParams.FeeSelectorDetailsParams,
@Assisted private val onDismiss: () -> Unit,
) : FeeSelectorComponent, AppComponentContext by appComponentContext {
private val model: FeeSelectorModel = getOrCreateModel(params = params)
override fun dismiss() {
model.dismiss()
onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
FeeSelectorModalBottomSheet(onDismiss = ::dismiss, state = state, feeSelectorIntents = model)
FeeSelectorModalBottomSheet(
onDismiss = ::dismiss,
state = state,
feeSelectorIntents = model,
feeDisplaySource = params.feeDisplaySource,
)
}
@AssistedFactory
@ -35,6 +41,7 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor(
override fun create(
context: AppComponentContext,
params: FeeSelectorParams.FeeSelectorDetailsParams,
onDismiss: () -> Unit,
): DefaultFeeSelectorComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.feeselector
import com.tangem.features.send.v2.api.feeselector.FeeSelectorReloadData
import com.tangem.features.send.v2.api.feeselector.FeeSelectorReloadListener
import com.tangem.features.send.v2.api.feeselector.FeeSelectorReloadTrigger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class DefaultFeeSelectorReload @Inject constructor() : FeeSelectorReloadListener, FeeSelectorReloadTrigger {
private val _reloadTriggerFlow = MutableSharedFlow<FeeSelectorReloadData>()
override val reloadTriggerFlow: Flow<FeeSelectorReloadData> = _reloadTriggerFlow.asSharedFlow()
override suspend fun triggerUpdate(data: FeeSelectorReloadData) {
_reloadTriggerFlow.emit(data)
}
}

View file

@ -2,8 +2,11 @@ package com.tangem.features.send.v2.feeselector.di
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.FeeSelectorComponent
import com.tangem.features.send.v2.api.feeselector.FeeSelectorReloadListener
import com.tangem.features.send.v2.api.feeselector.FeeSelectorReloadTrigger
import com.tangem.features.send.v2.feeselector.DefaultFeeSelectorBlockComponent
import com.tangem.features.send.v2.feeselector.DefaultFeeSelectorComponent
import com.tangem.features.send.v2.feeselector.DefaultFeeSelectorReload
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -21,4 +24,12 @@ internal interface FeeSelectorFeatureModule {
@Binds
@Singleton
fun bindBlockComponentFactory(factory: DefaultFeeSelectorBlockComponent.Factory): FeeSelectorBlockComponent.Factory
@Binds
@Singleton
fun provideFeeSelectorReloadTrigger(impl: DefaultFeeSelectorReload): FeeSelectorReloadTrigger
@Binds
@Singleton
fun provideFeeSelectorReloadListener(impl: DefaultFeeSelectorReload): FeeSelectorReloadListener
}

View file

@ -5,13 +5,13 @@ import com.tangem.features.send.v2.api.entity.FeeItem
internal interface FeeSelectorIntents {
fun onFeeItemSelected(feeItem: FeeItem)
fun onCustomFeeValueChange(index: Int, value: String)
fun onCustomFeeNextClick()
fun onNonceChange(value: String)
fun onDoneClick()
}
internal class StubFeeSelectorIntents : FeeSelectorIntents {
override fun onFeeItemSelected(feeItem: FeeItem) {}
override fun onCustomFeeValueChange(index: Int, value: String) {}
override fun onCustomFeeNextClick() {}
override fun onNonceChange(value: String) {}
override fun onDoneClick() {}
}

View file

@ -2,24 +2,27 @@ package com.tangem.features.send.v2.feeselector.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.AmountType
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.feeselector.FeeSelectorReloadListener
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.transformers.FeeItemSelectedTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.FeeSelectorErrorTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.FeeSelectorLoadedTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -27,29 +30,39 @@ import javax.inject.Inject
@ModelScoped
internal class FeeSelectorModel @Inject constructor(
paramsContainer: ParamsContainer,
private val feeSelectorReloadListener: FeeSelectorReloadListener,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
) : Model(), FeeSelectorIntents {
) : Model(), FeeSelectorIntents, FeeSelectorModelCallback {
private val params = paramsContainer.require<FeeSelectorParams>()
private var appCurrency: AppCurrency = AppCurrency.Default
val feeSelectorBottomSheet = SlotNavigation<Unit>()
val uiState: StateFlow<FeeSelectorUM>
field = MutableStateFlow<FeeSelectorUM>(params.state)
init {
initAppCurrency()
loadFee()
listenReloadTrigger()
}
fun updateState(feeSelectorUM: FeeSelectorUM) {
uiState.value = feeSelectorUM
}
fun dismiss() {
router.pop()
private fun listenReloadTrigger() {
feeSelectorReloadListener.reloadTriggerFlow
.onEach { data ->
if (data.removeSuggestedFee) {
uiState.update(FeeSelectorRemoveSuggestedTransformer)
}
loadFee()
}
.launchIn(modelScope)
}
private fun initAppCurrency() {
@ -59,6 +72,7 @@ internal class FeeSelectorModel @Inject constructor(
}
private fun loadFee() {
uiState.update(FeeSelectorLoadingTransformers)
modelScope.launch {
params.onLoadFee()
.fold(
@ -67,6 +81,7 @@ internal class FeeSelectorModel @Inject constructor(
uiState.update(
FeeSelectorLoadedTransformer(
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
appCurrency = appCurrency,
fees = fee,
suggestedFeeState = params.suggestedFeeState,
@ -80,7 +95,7 @@ internal class FeeSelectorModel @Inject constructor(
}
private fun isFeeApproximate(amountType: AmountType): Boolean {
val networkId = params.cryptoCurrencyStatus.currency.network.id
val networkId = params.feeCryptoCurrencyStatus.currency.network.id
return isFeeApproximateUseCase(networkId = networkId, amountType = amountType)
}
@ -89,15 +104,27 @@ internal class FeeSelectorModel @Inject constructor(
}
override fun onCustomFeeValueChange(index: Int, value: String) {
// TODO: [REDACTED_JIRA]
uiState.update(
FeeSelectorCustomValueChangedTransformer(
index = index,
value = value,
intents = this,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
),
)
}
override fun onCustomFeeNextClick() {
// TODO: [REDACTED_JIRA]
override fun onNonceChange(value: String) {
uiState.update(FeeSelectorNonceChangeTransformer(value = value))
}
override fun onDoneClick() {
params.callback.onFeeResult(uiState.value)
dismiss()
(params as? FeeSelectorParams.FeeSelectorDetailsParams)?.callback?.onFeeResult(uiState.value)
}
override fun onFeeResult(feeSelectorUM: FeeSelectorUM) {
uiState.value = feeSelectorUM
feeSelectorBottomSheet.dismiss()
}
}

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.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
@ -17,7 +17,7 @@ internal class FeeItemConverter(
private val feeSelectorIntents: FeeSelectorIntents,
private val appCurrency: AppCurrency,
cryptoCurrencyStatus: CryptoCurrencyStatus,
) : Converter<TransactionFee, ImmutableList<FeeItem>> {
) : Converter<FeeItemConverter.Input, ImmutableList<FeeItem>> {
private val customFeeFieldConverter = FeeSelectorCustomFieldConverter(
feeSelectorIntents = feeSelectorIntents,
@ -26,7 +26,7 @@ internal class FeeItemConverter(
normalFee = normalFee,
)
override fun convert(value: TransactionFee): ImmutableList<FeeItem> {
override fun convert(value: Input): ImmutableList<FeeItem> {
val fees = mutableListOf<FeeItem>()
when (suggestedFeeState) {
@ -38,26 +38,33 @@ internal class FeeItemConverter(
),
)
}
when (value) {
when (value.transactionFee) {
is TransactionFee.Choosable -> {
fees.add(FeeItem.Slow(fee = value.minimum))
fees.add(FeeItem.Market(fee = value.normal))
fees.add(FeeItem.Fast(fee = value.priority))
fees.add(FeeItem.Slow(fee = value.transactionFee.minimum))
fees.add(FeeItem.Market(fee = value.transactionFee.normal))
fees.add(FeeItem.Fast(fee = value.transactionFee.priority))
}
is TransactionFee.Single -> {
fees.add(FeeItem.Market(fee = value.normal))
fees.add(FeeItem.Market(fee = value.transactionFee.normal))
}
}
val customFeeFields = customFeeFieldConverter.convert(normalFee)
if (customFeeFields.isNotEmpty()) {
fees.add(
FeeItem.Custom(
fee = customFeeFieldConverter.convertBack(customFeeFields),
customValues = customFeeFields,
),
)
}
val customFee = value.customFee ?: constructCustomFee()
customFee?.let(fees::add)
return fees.toImmutableList()
}
private fun constructCustomFee(): FeeItem.Custom? {
val customFeeFields = customFeeFieldConverter.convert(normalFee)
if (customFeeFields.isEmpty()) return null
return FeeItem.Custom(
fee = customFeeFieldConverter.convertBack(customFeeFields),
customValues = customFeeFields,
)
}
data class Input(val transactionFee: TransactionFee, val customFee: FeeItem.Custom?)
}

View file

@ -5,12 +5,12 @@ 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.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.bitcoin.BitcoinCustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.kaspa.KaspaCustomFeeConverter
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.utils.converter.TwoWayConverter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -25,7 +25,7 @@ internal class FeeSelectorCustomFieldConverter(
private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
EthereumCustomFeeConverter(
onCustomFeeValueChange = feeSelectorIntents::onCustomFeeValueChange,
onNextClick = feeSelectorIntents::onCustomFeeNextClick,
onNextClick = null,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
@ -34,7 +34,7 @@ internal class FeeSelectorCustomFieldConverter(
private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
BitcoinCustomFeeConverter(
onCustomFeeValueChange = feeSelectorIntents::onCustomFeeValueChange,
onNextClick = feeSelectorIntents::onCustomFeeNextClick,
onNextClick = null,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
@ -77,38 +77,43 @@ internal class FeeSelectorCustomFieldConverter(
}
}
fun onValueChange(feeSelectorState: FeeSelectorUM.Content, index: Int, value: String) =
when (val fee = feeSelectorState.fees.normal) {
is Fee.Ethereum -> ethereumCustomFeeConverter.onValueChange(
feeValue = fee,
customValues = feeSelectorState.customValues,
index = index,
value = value,
)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.onValueChange(
customValues = feeSelectorState.customValues,
index = index,
value = value,
txSize = fee.txSize,
)
is Fee.Kaspa -> kaspaCustomFeeConverter.onValueChange(
customValues = feeSelectorState.customValues,
index = index,
value = value,
)
else -> feeSelectorState.customValues
}
fun tryAutoFixValue(feeSelectorState: FeeSelectorUM.Content) = when (feeSelectorState.fees) {
is TransactionFee.Choosable -> feeSelectorState.fees.minimum
is TransactionFee.Single -> feeSelectorState.fees.normal
}.let {
when (it) {
is Fee.Kaspa -> kaspaCustomFeeConverter.tryAutoFixValue(
minimumFee = it,
customValues = feeSelectorState.customValues,
)
else -> feeSelectorState.customValues
}
fun onValueChange(
feeSelectorState: FeeSelectorUM.Content,
customValues: ImmutableList<CustomFeeFieldUM>,
index: Int,
value: String,
) = when (val fee = feeSelectorState.fees.normal) {
is Fee.Ethereum -> ethereumCustomFeeConverter.onValueChange(
feeValue = fee,
customValues = customValues,
index = index,
value = value,
)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.onValueChange(
customValues = customValues,
index = index,
value = value,
txSize = fee.txSize,
)
is Fee.Kaspa -> kaspaCustomFeeConverter.onValueChange(
customValues = customValues,
index = index,
value = value,
)
else -> customValues
}
fun tryAutoFixValue(feeSelectorState: FeeSelectorUM.Content, customValues: ImmutableList<CustomFeeFieldUM>) =
when (val fees = feeSelectorState.fees) {
is TransactionFee.Choosable -> fees.minimum
is TransactionFee.Single -> fees.normal
}.let {
when (it) {
is Fee.Kaspa -> kaspaCustomFeeConverter.tryAutoFixValue(
minimumFee = it,
customValues = customValues,
)
else -> customValues
}
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class FeeSelectorCustomValueChangedTransformer(
private val index: Int,
private val value: String,
private val intents: FeeSelectorIntents,
private val appCurrency: AppCurrency,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : Transformer<FeeSelectorUM> {
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
val state = prevState as? FeeSelectorUM.Content ?: return prevState
val customFee = state.feeItems.filterIsInstance<FeeItem.Custom>().firstOrNull() ?: return prevState
val customFeeConverter = FeeSelectorCustomFieldConverter(
feeSelectorIntents = intents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
normalFee = state.selectedFeeItem.fee,
)
val updatedCustomValues = customFeeConverter.onValueChange(state, customFee.customValues, index, value)
val newCustomFee = customFee.copy(
fee = customFeeConverter.convertBack(updatedCustomValues),
customValues = updatedCustomValues,
)
return state.copy(
feeItems = state.feeItems.map { if (it is FeeItem.Custom) newCustomFee else it }.toImmutableList(),
selectedFeeItem = newCustomFee,
)
}
}

View file

@ -1,19 +1,21 @@
package com.tangem.features.send.v2.feeselector.model.transformers
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.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
@Suppress("LongParameterList")
internal class FeeSelectorLoadedTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val fees: TransactionFee,
private val suggestedFeeState: FeeSelectorParams.SuggestedFeeState,
@ -26,32 +28,50 @@ internal class FeeSelectorLoadedTransformer(
normalFee = fees.normal,
feeSelectorIntents = feeSelectorIntents,
appCurrency = appCurrency,
cryptoCurrencyStatus = cryptoCurrencyStatus,
cryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
val feeItems: ImmutableList<FeeItem> = feeItemsConverter.convert(fees)
val prevCustomFee = if (prevState is FeeSelectorUM.Content) {
prevState.feeItems.find { it is FeeItem.Custom } as? FeeItem.Custom
} else {
null
}
val feeItems: ImmutableList<FeeItem> = feeItemsConverter.convert(FeeItemConverter.Input(fees, prevCustomFee))
val selectedFee = when (prevState) {
is FeeSelectorUM.Content -> feeItems.first { it.isSame(prevState.selectedFeeItem) }
is FeeSelectorUM.Content -> feeItems.first { it.isSameClass(prevState.selectedFeeItem) }
is FeeSelectorUM.Error,
FeeSelectorUM.Loading,
-> feeItems.find { it is FeeItem.Suggested } ?: feeItems.first { it is FeeItem.Market }
}
val nonce = ((prevState as? FeeSelectorUM.Content)?.feeNonce as? FeeNonce.Nonce)?.nonce
return FeeSelectorUM.Content(
fees = fees,
feeItems = feeItems,
selectedFeeItem = selectedFee,
isFeeApproximate = isFeeApproximate,
feeFiatRateUM = cryptoCurrencyStatus.value.fiatRate?.let { rate ->
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = isFeeApproximate,
isFeeConvertibleToFiat = feeCryptoCurrencyStatus.currency.network.hasFiatFeeRate,
isTronToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token &&
isTron(cryptoCurrencyStatus.currency.network.rawId),
),
feeFiatRateUM = feeCryptoCurrencyStatus.value.fiatRate?.let { rate ->
FeeFiatRateUM(
rate = rate,
appCurrency = appCurrency,
)
},
displayNonceInput = false,
nonce = null,
onNonceChange = {},
feeNonce = if (fees.normal is Fee.Ethereum) {
FeeNonce.Nonce(
nonce = nonce,
onNonceChange = feeSelectorIntents::onNonceChange,
)
} else {
FeeNonce.None
},
)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
internal object FeeSelectorLoadingTransformers : Transformer<FeeSelectorUM> {
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
return FeeSelectorUM.Loading
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.features.send.v2.api.entity.FeeNonce
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
internal class FeeSelectorNonceChangeTransformer(
private val value: String,
) : Transformer<FeeSelectorUM> {
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
val state = prevState as? FeeSelectorUM.Content ?: return prevState
val feeNonce = state.feeNonce as? FeeNonce.Nonce ?: return prevState
if (value.isEmpty()) {
return state.copy(feeNonce = feeNonce.copy(null))
}
val nonce = value.toBigIntegerOrNull() ?: return prevState
return state.copy(feeNonce = feeNonce.copy(nonce = nonce))
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal object FeeSelectorRemoveSuggestedTransformer : Transformer<FeeSelectorUM> {
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
val state = prevState as? FeeSelectorUM.Content ?: return prevState
val newFeeItems = state.feeItems.filterNot { item -> item is FeeItem.Suggested }
val newSelectedFee = if (state.selectedFeeItem is FeeItem.Suggested) {
state.feeItems.first { item -> item is FeeItem.Market }
} else {
state.selectedFeeItem
}
return state.copy(
feeItems = newFeeItems.toImmutableList(),
selectedFeeItem = newSelectedFee,
)
}
}

View file

@ -0,0 +1,196 @@
package com.tangem.features.send.v2.feeselector.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
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.ui.amountScreen.utils.getFiatString
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN
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.core.ui.res.TangemThemePreview
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.impl.R
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Composable
internal fun FeeSelectorBlockContent(state: FeeSelectorUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(TangemTheme.colors.background.action)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.ic_fee_new_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
FeeSelectorDescription(state = state)
}
}
@Composable
private fun FeeSelectorDescription(state: FeeSelectorUM, modifier: Modifier = Modifier) {
Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween) {
FeeSelectorStaticPart(modifier = Modifier.weight(1f))
when (state) {
is FeeSelectorUM.Content -> FeeContent(state)
is FeeSelectorUM.Loading -> FeeLoading()
is FeeSelectorUM.Error -> FeeError()
}
}
}
@Composable
private fun FeeSelectorStaticPart(modifier: Modifier = Modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing4)
.weight(1f, fill = false),
text = stringResourceSafe(R.string.common_network_fee_title),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing6)
.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_token_info_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Composable
private fun FeeError() {
Text(
text = EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun FeeLoading() {
TextShimmer(
radius = TangemTheme.dimens.radius3,
style = TangemTheme.typography.body1,
modifier = Modifier.width(width = TangemTheme.dimens.size90),
)
}
@Composable
private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifier) {
val fiatRate = state.feeFiatRateUM
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
EllipsisText(
text = if (fiatRate != null) {
getFiatString(
value = state.selectedFeeItem.fee.amount.value,
rate = fiatRate.rate,
appCurrency = fiatRate.appCurrency,
approximate = state.feeExtraInfo.isFeeApproximate,
)
} else {
state.selectedFeeItem.fee.amount.value.format {
crypto(
symbol = state.selectedFeeItem.fee.amount.currencySymbol,
decimals = state.selectedFeeItem.fee.amount.decimals,
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
}
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.End,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
)
Icon(
modifier = Modifier.size(width = 18.dp, height = 24.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeSelectorBlockContent_Preview(@PreviewParameter(FeeSelectorUMProvider::class) state: FeeSelectorUM) {
TangemThemePreview {
FeeSelectorBlockContent(modifier = Modifier.fillMaxWidth(), state = state)
}
}
private class FeeSelectorUMProvider : PreviewParameterProvider<FeeSelectorUM> {
private val maxFeeItem = FeeItem.Market(
fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Ethereum)),
)
private val lowFeeItem =
FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum)))
override val values: Sequence<FeeSelectorUM> = sequenceOf(
FeeSelectorUM.Content(
feeItems = persistentListOf(lowFeeItem),
selectedFeeItem = lowFeeItem,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeNonce = FeeNonce.None,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500"),
appCurrency = AppCurrency.Default,
),
fees = TransactionFee.Single(lowFeeItem.fee),
),
FeeSelectorUM.Content(
feeItems = persistentListOf(maxFeeItem),
selectedFeeItem = maxFeeItem,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeNonce = FeeNonce.None,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500000000000"),
appCurrency = AppCurrency.Default,
),
fees = TransactionFee.Single(maxFeeItem.fee),
),
FeeSelectorUM.Error(GetFeeError.UnknownError),
FeeSelectorUM.Loading,
)
}

View file

@ -4,10 +4,8 @@ import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
@ -17,7 +15,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
@ -31,6 +28,7 @@ import androidx.compose.ui.util.fastForEachIndexed
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.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.atoms.text.EllipsisText
@ -39,6 +37,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
import com.tangem.core.ui.components.inputrow.InputRowEnter
import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmountV2
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
@ -47,10 +46,8 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents
import com.tangem.features.send.v2.impl.R
@ -58,12 +55,12 @@ import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import java.math.BigInteger
@Composable
internal fun FeeSelectorModalBottomSheet(
state: FeeSelectorUM,
feeSelectorIntents: FeeSelectorIntents,
feeDisplaySource: FeeSelectorParams.FeeDisplaySource,
onDismiss: () -> Unit,
) {
if (state !is FeeSelectorUM.Content) return
@ -76,17 +73,13 @@ internal fun FeeSelectorModalBottomSheet(
),
containerColor = TangemTheme.colors.background.primary,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
startIconRes = R.drawable.ic_back_24,
onStartClick = onDismiss,
)
FeeTitle(feeDisplaySource = feeDisplaySource, onDismiss = onDismiss)
},
content = {
FeeSelectorItems(
state = state,
feeSelectorIntents = feeSelectorIntents,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp),
modifier = Modifier.padding(vertical = 4.dp, horizontal = 13.dp),
)
},
footer = {
@ -101,6 +94,26 @@ internal fun FeeSelectorModalBottomSheet(
)
}
@Composable
private fun FeeTitle(feeDisplaySource: FeeSelectorParams.FeeDisplaySource, onDismiss: () -> Unit) {
when (feeDisplaySource) {
FeeSelectorParams.FeeDisplaySource.Screen -> {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
endIconRes = R.drawable.ic_close_24,
onEndClick = onDismiss,
)
}
FeeSelectorParams.FeeDisplaySource.BottomSheet -> {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
startIconRes = R.drawable.ic_back_24,
onStartClick = onDismiss,
)
}
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun FeeSelectorItems(
@ -111,7 +124,7 @@ private fun FeeSelectorItems(
Column(modifier = modifier) {
val feeFiatRateUM = state.feeFiatRateUM
state.feeItems.fastForEachIndexed { index, item ->
val isSelected = item.isSame(state.selectedFeeItem)
val isSelected = item.isSameClass(state.selectedFeeItem)
val lastItem = index == state.feeItems.size - 1
val iconTint by animateColorAsState(
targetValue = if (isSelected) TangemTheme.colors.icon.accent else TangemTheme.colors.text.tertiary,
@ -128,21 +141,7 @@ private fun FeeSelectorItems(
val itemModifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors.background.primary)
.then(
if (isSelected) {
Modifier
.border(
width = 2.5.dp,
color = iconTint.copy(alpha = 0.2F),
shape = RoundedCornerShape(16.dp),
)
.padding(2.5.dp)
.border(width = 1.dp, color = iconTint, shape = RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
} else {
Modifier
},
)
.selectedBorder(isSelected = isSelected)
.clickableSingle(onClick = { feeSelectorIntents.onFeeItemSelected(item) })
when (item) {
is FeeItem.Suggested -> RegularFeeItemContent(
@ -156,7 +155,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -182,7 +181,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -208,7 +207,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -234,7 +233,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -255,9 +254,8 @@ private fun FeeSelectorItems(
isSelected = isSelected,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
displayNonceInput = state.displayNonceInput,
nonce = state.nonce,
onNonceChange = state.onNonceChange,
onValueChange = feeSelectorIntents::onCustomFeeValueChange,
nonce = state.feeNonce,
)
}
}
@ -271,9 +269,8 @@ private fun CustomFeeBlock(
isSelected: Boolean,
iconBackgroundColor: Color,
iconTint: Color,
displayNonceInput: Boolean,
nonce: BigInteger?,
onNonceChange: (String) -> Unit,
onValueChange: (Int, String) -> Unit,
nonce: FeeNonce,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@ -305,10 +302,8 @@ private fun CustomFeeBlock(
) {
ExpandedCustomFeeItems(
customFeeFields = customFee.customValues,
onValueChange = { _, _ -> },
displayNonceInput = displayNonceInput,
onValueChange = onValueChange,
nonce = nonce,
onNonceChange = onNonceChange,
)
}
}
@ -318,14 +313,12 @@ private fun CustomFeeBlock(
private fun ExpandedCustomFeeItems(
customFeeFields: ImmutableList<CustomFeeFieldUM>,
onValueChange: (Int, String) -> Unit,
displayNonceInput: Boolean,
nonce: BigInteger?,
onNonceChange: (String) -> Unit,
nonce: FeeNonce,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
customFeeFields.fastForEachIndexed { index, field ->
val showDivider = index != customFeeFields.size - 1 || displayNonceInput
val showDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce
if (field.label != null) {
InputRowEnterInfoAmountV2(
text = field.value,
@ -334,6 +327,7 @@ private fun ExpandedCustomFeeItems(
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
info = field.label,
description = field.footer,
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
onValueChange = { onValueChange(index, it) },
@ -347,6 +341,7 @@ private fun ExpandedCustomFeeItems(
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
symbol = field.symbol,
description = field.footer,
onValueChange = { onValueChange(index, it) },
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
@ -355,18 +350,21 @@ private fun ExpandedCustomFeeItems(
}
}
if (displayNonceInput) {
// TODO implement v2 input without binding to amount
InputRowEnterInfoAmountV2(
text = nonce?.toString() ?: "",
decimals = 0,
if (nonce is FeeNonce.Nonce) {
InputRowEnter(
text = nonce.nonce?.toString().orEmpty(),
title = resourceReference(R.string.send_nonce),
titleColor = TangemTheme.colors.text.tertiary,
symbol = null,
onValueChange = onNonceChange,
description = resourceReference(R.string.send_nonce_footer),
onValueChange = nonce.onNonceChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
placeholder = resourceReference(R.string.send_nonce_hint),
titleColor = TangemTheme.colors.text.secondary,
showDivider = false,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
@ -474,7 +472,12 @@ private fun FeeSelectorBS_Preview(
state: FeeSelectorUM.Content,
) {
TangemThemePreview {
FeeSelectorModalBottomSheet(onDismiss = {}, state = state, feeSelectorIntents = StubFeeSelectorIntents())
FeeSelectorModalBottomSheet(
onDismiss = {},
state = state,
feeSelectorIntents = StubFeeSelectorIntents(),
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet,
)
}
}
@ -495,14 +498,17 @@ private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider<
// amount = Amount(value = BigDecimal("0.02"), blockchain = Blockchain.Ethereum),
// ),
selectedFeeItem = customFeeItem,
isFeeApproximate = true,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = true,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal.TEN,
appCurrency = AppCurrency.Default,
),
displayNonceInput = true,
onNonceChange = {},
nonce = null,
feeNonce = FeeNonce.None,
fees = TransactionFee.Single(customFeeItem.fee),
),
),
)

View file

@ -14,12 +14,14 @@ import com.arkivanov.decompose.value.subscribe
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
@ -30,11 +32,14 @@ import com.tangem.features.send.v2.common.utils.safeNextClick
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.send.confirm.SendConfirmComponent
import com.tangem.features.send.v2.send.model.SendModel
import com.tangem.features.send.v2.send.success.SendConfirmSuccessComponent
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent
import com.tangem.features.send.v2.subcomponents.destination.DefaultSendDestinationComponent
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.subcomponents.destination.DefaultSendDestinationBlockComponent
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams
import dagger.assisted.Assisted
@ -44,10 +49,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
@Suppress("LargeClass")
internal class DefaultSendComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendComponent.Params,
private val analyticsEventHandler: AnalyticsEventHandler,
private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
) : SendComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<CommonSendRoute>()
@ -142,7 +149,8 @@ internal class DefaultSendComponent @AssistedInject constructor(
is CommonSendRoute.Destination -> getDestinationComponent(factoryContext, route)
is CommonSendRoute.Amount -> getAmountComponent(factoryContext, route)
is CommonSendRoute.Fee -> getFeeComponent(factoryContext)
CommonSendRoute.Confirm -> getConfirmComponent(factoryContext)
is CommonSendRoute.Confirm -> getConfirmComponent(factoryContext)
is CommonSendRoute.ConfirmSuccess -> getConfirmSuccessComponent(factoryContext)
}
private fun getDestinationComponent(
@ -288,7 +296,11 @@ internal class DefaultSendComponent @AssistedInject constructor(
callback = model,
predefinedValues = model.predefinedValues,
onLoadFee = model::loadFee,
onSendTransaction = {
innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess)
},
),
feeSelectorComponentFactory = feeSelectorComponentFactory,
)
} else {
model.showAlertError()
@ -296,6 +308,69 @@ internal class DefaultSendComponent @AssistedInject constructor(
}
}
private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent {
val state = model.uiState.value
val sendAmount = (state.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value
val txUrl = (state.confirmUM as? ConfirmUM.Success)?.txUrl
val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value
val feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatusFlow.value
if (sendAmount == null ||
destinationAddress == null ||
txUrl == null
) {
model.showAlertError()
return getStubComponent()
}
val destinationBlockComponent =
DefaultSendDestinationBlockComponent(
appComponentContext = child("sendConfirmDestinationBlock"),
params = SendDestinationComponentParams.DestinationBlockParams(
state = model.uiState.value.destinationUM,
analyticsCategoryName = analyticCategoryName,
userWalletId = model.userWallet.walletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
blockClickEnableFlow = MutableStateFlow(true),
predefinedValues = model.predefinedValues,
),
onResult = { },
onClick = {},
)
val feeBlockComponent = SendFeeBlockComponent(
appComponentContext = child("sendConfirmFeeBlock"),
params = SendFeeComponentParams.FeeBlockParams(
state = model.uiState.value.feeUM,
analyticsCategoryName = analyticCategoryName,
userWallet = model.userWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
appCurrency = model.appCurrency,
sendAmount = sendAmount,
destinationAddress = destinationAddress,
blockClickEnableFlow = MutableStateFlow(true),
onLoadFee = model::loadFee,
),
onResult = { },
onClick = {},
)
return SendConfirmSuccessComponent(
appComponentContext = factoryContext,
params = SendConfirmSuccessComponent.Params(
sendUMFlow = model.uiState,
feeBlockComponent = feeBlockComponent,
destinationBlockComponent = destinationBlockComponent,
analyticsCategoryName = analyticCategoryName,
currentRoute = currentRoute,
txUrl = txUrl,
callback = model,
),
)
}
private fun getStubComponent() = StubComponent()
class StubComponent : ComposableContentComponent {

View file

@ -14,9 +14,11 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.entity.PredefinedValues
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
@ -35,6 +37,7 @@ import kotlinx.coroutines.flow.*
internal class SendConfirmComponent(
appComponentContext: AppComponentContext,
params: Params,
private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendConfirmModel = getOrCreateModel(params = params)
@ -92,6 +95,19 @@ internal class SendConfirmComponent(
onClick = model::showEditFee,
)
private val feeSelectorBlockComponent = feeSelectorComponentFactory.create(
context = appComponentContext,
params = FeeSelectorParams.FeeSelectorBlockParams(
state = model.uiState.value.feeSelectorUM,
onLoadFee = params.onLoadFee,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
suggestedFeeState = model.suggestedFeeState,
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
),
onResult = model::onFeeResult,
)
private val notificationsComponent = DefaultSendNotificationsComponent(
appComponentContext = child("sendConfirmNotifications"),
params = SendNotificationsComponent.Params(
@ -136,6 +152,7 @@ internal class SendConfirmComponent(
destinationBlockComponent = destinationBlockComponent,
amountBlockComponent = amountBlockComponent,
feeBlockComponent = feeBlockComponent,
feeSelectorBlockComponent = feeSelectorBlockComponent,
notificationsComponent = notificationsComponent,
notificationsUM = notificationState,
)
@ -155,6 +172,7 @@ internal class SendConfirmComponent(
val isBalanceHidingFlow: StateFlow<Boolean>,
val predefinedValues: PredefinedValues,
val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
val onSendTransaction: () -> Unit,
)
interface ModelCallback {

View file

@ -35,7 +35,13 @@ import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeNonce
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.SendBalanceUpdater
import com.tangem.features.send.v2.common.SendConfirmAlertFactory
@ -49,13 +55,13 @@ import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmIn
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSendingStateTransformer
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSentStateTransformer
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmationNotificationsTransformer
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmationNotificationsTransformerV2
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.model.checkAndCalculateSubtractedAmount
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.extensions.stripZeroPlainString
@ -86,13 +92,14 @@ internal class SendConfirmModel @Inject constructor(
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger,
private val sendFeeCheckReloadListener: SendFeeCheckReloadListener,
private val notificationsUpdateTrigger: NotificationsUpdateTrigger,
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val notificationsUpdateListener: SendNotificationsUpdateListener,
private val alertFactory: SendConfirmAlertFactory,
private val sendAnalyticHelper: SendAnalyticHelper,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
sendBalanceUpdaterFactory: SendBalanceUpdater.Factory,
) : Model(), SendConfirmClickIntents {
) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback {
private val params: SendConfirmComponent.Params = paramsContainer.require()
@ -115,6 +122,8 @@ internal class SendConfirmModel @Inject constructor(
get() = uiState.value.feeUM as? FeeUM.Content
private val feeSelectorUM
get() = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
private val feeUMV2
get() = uiState.value.feeSelectorUM as? FeeSelectorUMRedesigned.Content
val confirmData: ConfirmData
get() = ConfirmData(
@ -129,6 +138,7 @@ internal class SendConfirmModel @Inject constructor(
private var sendIdleTimer: Long = 0L
private var isAmountSubtractAvailable = false
internal var suggestedFeeState: FeeSelectorParams.SuggestedFeeState = FeeSelectorParams.SuggestedFeeState.None
init {
modelScope.launch {
@ -291,6 +301,7 @@ internal class SendConfirmModel @Inject constructor(
isShowTapHelp = isShowTapHelp,
walletName = stringReference(userWallet.name),
).transform(uiState.value.confirmUM),
confirmData = confirmData,
)
}
updateConfirmNotifications()
@ -299,16 +310,25 @@ internal class SendConfirmModel @Inject constructor(
}
private fun subscribeOnNotificationsUpdateTrigger() {
notificationsUpdateTrigger.hasErrorFlow
notificationsUpdateListener.hasErrorFlow
.onEach { hasError ->
_uiState.update {
val feeUM = it.feeUM as? FeeUM.Content
val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeSelectorUM != null,
) ?: it.confirmUM,
)
if (_uiState.value.isRedesignEnabled) {
val feeUM = it.feeSelectorUM as? FeeSelectorUMRedesigned.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeUM != null,
) ?: it.confirmUM,
)
} else {
val feeUM = it.feeUM as? FeeUM.Content
val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeSelectorUM != null,
) ?: it.confirmUM,
)
}
}
}
.launchIn(modelScope)
@ -318,9 +338,18 @@ internal class SendConfirmModel @Inject constructor(
val amountValue = amountState?.amountTextField?.cryptoAmount?.value ?: return
val destination = destinationUM?.addressTextField?.actualAddress ?: return
val memo = destinationUM?.memoTextField?.value
val fee = feeSelectorUM?.selectedFee
val isRedesignEnabled = uiState.value.isRedesignEnabled
val fee = if (isRedesignEnabled) {
feeUMV2?.selectedFeeItem?.fee
} else {
feeSelectorUM?.selectedFee
}
val nonce = if (isRedesignEnabled) {
(feeUMV2?.feeNonce as? FeeNonce.Nonce)?.nonce
} else {
feeSelectorUM?.nonce
}
val feeValue = fee?.amount?.value ?: return
val nonce = feeSelectorUM?.nonce
val receivingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
@ -385,6 +414,10 @@ internal class SendConfirmModel @Inject constructor(
addTokenToWalletIfNeeded()
sendBalanceUpdater.scheduleUpdates()
sendAnalyticHelper.sendSuccessAnalytics(cryptoCurrency, uiState.value)
if (uiState.value.isRedesignEnabled) {
params.callback.onResult(uiState.value)
params.onSendTransaction()
}
},
)
}
@ -444,14 +477,25 @@ internal class SendConfirmModel @Inject constructor(
)
_uiState.update {
it.copy(
confirmUM = SendConfirmationNotificationsTransformer(
feeUM = uiState.value.feeUM,
amountUM = uiState.value.amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
).transform(uiState.value.confirmUM),
confirmUM = if (uiState.value.isRedesignEnabled) {
SendConfirmationNotificationsTransformerV2(
feeSelectorUM = uiState.value.feeSelectorUM,
amountUM = uiState.value.amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
).transform(uiState.value.confirmUM)
} else {
SendConfirmationNotificationsTransformer(
feeUM = uiState.value.feeUM,
amountUM = uiState.value.amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
).transform(uiState.value.confirmUM)
},
)
}
}
@ -463,23 +507,35 @@ internal class SendConfirmModel @Inject constructor(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { it.second is CommonSendRoute.Confirm }.onEach { (state, _) ->
).filter {
it.second is CommonSendRoute.Confirm
}.onEach { (state, _) ->
val amountUM = state.amountUM as? AmountState.Data
val confirmUM = state.confirmUM
val isReadyToSend = confirmUM is ConfirmUM.Content && !confirmUM.isSending
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
title = resourceReference(
id = R.string.send_summary_title,
formatArgs = wrappedList(params.cryptoCurrencyStatus.currency.name),
),
title = if (state.isRedesignEnabled) {
stringReference("")
} else {
resourceReference(
id = R.string.send_summary_title,
formatArgs = wrappedList(params.cryptoCurrencyStatus.currency.name),
)
},
subtitle = if (uiState.value.isRedesignEnabled) {
null
} else {
amountUM?.title
},
backIconRes = R.drawable.ic_close_24,
backIconRes = if (state.isRedesignEnabled) {
when (confirmUM) {
is ConfirmUM.Success -> R.drawable.ic_close_24
else -> R.drawable.ic_back_24
}
} else {
R.drawable.ic_close_24
},
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
@ -491,31 +547,7 @@ internal class SendConfirmModel @Inject constructor(
)
appRouter.pop()
},
primaryButton = NavigationButton(
textReference = when (confirmUM) {
is ConfirmUM.Success -> resourceReference(R.string.common_close)
is ConfirmUM.Content -> if (confirmUM.isSending) {
resourceReference(R.string.send_sending)
} else {
resourceReference(R.string.common_send)
}
else -> resourceReference(R.string.common_send)
},
iconRes = R.drawable.ic_tangem_24.takeIf { isReadyToSend },
isEnabled = confirmUM.isPrimaryButtonEnabled,
isHapticClick = isReadyToSend,
onClick = {
when (confirmUM) {
is ConfirmUM.Success -> appRouter.pop()
is ConfirmUM.Content -> if (confirmUM.isSending) {
return@NavigationButton
} else {
onSendClick()
}
else -> return@NavigationButton
}
},
),
primaryButton = primaryButtonUM(),
prevButton = null,
secondaryPairButtonsUM = (
NavigationButton(
@ -534,6 +566,42 @@ internal class SendConfirmModel @Inject constructor(
}.launchIn(modelScope)
}
private fun primaryButtonUM(): NavigationButton {
val confirmUM = uiState.value.confirmUM
val isReadyToSend = confirmUM is ConfirmUM.Content && !confirmUM.isSending
return NavigationButton(
textReference = when (confirmUM) {
is ConfirmUM.Success -> resourceReference(R.string.common_close)
is ConfirmUM.Content -> if (confirmUM.isSending) {
resourceReference(R.string.send_sending)
} else {
resourceReference(R.string.common_send)
}
else -> resourceReference(R.string.common_send)
},
iconRes = R.drawable.ic_tangem_24.takeIf { isReadyToSend },
isEnabled = confirmUM.isPrimaryButtonEnabled,
isHapticClick = isReadyToSend,
onClick = {
when (confirmUM) {
is ConfirmUM.Success -> appRouter.pop()
is ConfirmUM.Content -> if (confirmUM.isSending) {
return@NavigationButton
} else {
onSendClick()
}
else -> return@NavigationButton
}
},
)
}
override fun onFeeResult(feeSelectorUM: FeeSelectorUMRedesigned) {
sendIdleTimer = SystemClock.elapsedRealtime()
_uiState.update { it.copy(feeSelectorUM = feeSelectorUM) }
updateConfirmNotifications()
}
private companion object {
const val CHECK_FEE_UPDATE_DELAY = 10_000L
}

View file

@ -0,0 +1,110 @@
package com.tangem.features.send.v2.send.confirm.model.transformers
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.wrappedList
import com.tangem.core.ui.format.bigdecimal.fiat
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.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.common.utils.formatFooterFiatFee
import com.tangem.features.send.v2.common.utils.getTronTokenFeeSendingText
import com.tangem.features.send.v2.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList
internal class SendConfirmationNotificationsTransformerV2(
private val feeSelectorUM: FeeSelectorUM,
private val amountUM: AmountState,
private val analyticsEventHandler: AnalyticsEventHandler,
private val cryptoCurrency: CryptoCurrency,
private val appCurrency: AppCurrency,
private val analyticsCategoryName: String,
) : Transformer<ConfirmUM> {
override fun transform(prevState: ConfirmUM): ConfirmUM {
val state = prevState as? ConfirmUM.Content ?: return prevState
val feeSelectorUM = feeSelectorUM as? FeeSelectorUM.Content ?: return prevState
return state.copy(
sendingFooter = getSendingFooterText(),
notifications = buildList {
addTooHighNotification(feeSelectorUM)
addTooLowNotification(feeSelectorUM)
}.toPersistentList(),
)
}
private fun MutableList<NotificationUM>.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) {
if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM)) {
add(NotificationUM.Warning.FeeTooLow)
analyticsEventHandler.send(
CommonSendAnalyticEvents.NoticeTransactionDelays(
categoryName = analyticsCategoryName,
token = cryptoCurrency.symbol,
),
)
}
}
private fun MutableList<NotificationUM>.addTooHighNotification(feeSelectorUM: FeeSelectorUM.Content) {
val (isFeeTooHigh, diff) = FeeCalculationUtils.checkIfCustomFeeTooHigh(feeSelectorUM)
if (isFeeTooHigh) {
add(NotificationUM.Warning.TooHigh(diff))
}
}
private fun getSendingFooterText(): TextReference {
val feeSelectorUM = feeSelectorUM as? FeeSelectorUM.Content
val amountUM = amountUM as? AmountState.Data
val fee = feeSelectorUM?.selectedFeeItem?.fee
if (fee == null || amountUM == null) return TextReference.EMPTY
val fiatAmountValue = amountUM.amountTextField.fiatAmount.value
val fiatFeeValue = feeSelectorUM.feeFiatRateUM?.rate?.let { fee.amount.value?.multiply(it) }
val fiatSendingValue = if (feeSelectorUM.feeFiatRateUM != null) {
fiatFeeValue?.let { fiatAmountValue?.plus(it) }
} else {
fiatAmountValue
}
val fiatSending = fiatSendingValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
val fiatFee = formatFooterFiatFee(
amount = fee.amount.copy(value = fiatFeeValue),
isFeeConvertibleToFiat = feeSelectorUM.feeFiatRateUM != null,
isFeeApproximate = feeSelectorUM.feeExtraInfo.isFeeApproximate,
appCurrency = appCurrency,
)
return if (fee is Fee.Tron) {
getTronTokenFeeSendingText(
fee = fee,
fiatFee = fiatFee,
fiatSending = stringReference(fiatSending),
)
} else {
resourceReference(
id = if (feeSelectorUM.feeFiatRateUM != null) {
R.string.send_summary_transaction_description
} else {
R.string.send_summary_transaction_description_no_fiat_fee
},
formatArgs = wrappedList(fiatSending, fiatFee),
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.send.v2.send.confirm.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@ -9,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.SpacerHMax
@ -19,6 +21,7 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.common.ui.SendingText
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.common.ui.tapHelp
@ -40,6 +43,7 @@ internal fun SendConfirmContent(
destinationBlockComponent: DefaultSendDestinationBlockComponent,
amountBlockComponent: SendAmountBlockComponent,
feeBlockComponent: SendFeeBlockComponent,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
notificationsComponent: DefaultSendNotificationsComponent,
notificationsUM: ImmutableList<NotificationUM>,
) {
@ -54,6 +58,7 @@ internal fun SendConfirmContent(
destinationBlockComponent = destinationBlockComponent,
amountBlockComponent = amountBlockComponent,
feeBlockComponent = feeBlockComponent,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
if (confirmUM != null) {
tapHelp(isDisplay = confirmUM.showTapHelp)
@ -79,34 +84,45 @@ private fun LazyListScope.blocks(
destinationBlockComponent: DefaultSendDestinationBlockComponent,
amountBlockComponent: SendAmountBlockComponent,
feeBlockComponent: SendFeeBlockComponent,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
) {
item(key = BLOCKS_KEY) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
AnimatedVisibility(
visible = uiState.confirmUM is ConfirmUM.Success,
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12),
) {
val wrappedConfirmUM = remember(this) { uiState.confirmUM as ConfirmUM.Success }
TransactionDoneTitle(
title = resourceReference(R.string.sent_transaction_sent_title),
subtitle = resourceReference(
R.string.send_date_format,
wrappedList(
wrappedConfirmUM.transactionDate.toTimeFormat(DateTimeFormatters.dateFormatter),
wrappedConfirmUM.transactionDate.toTimeFormat(),
),
),
modifier = Modifier.padding(vertical = 12.dp),
)
}
if (uiState.isRedesignEnabled) {
amountBlockComponent.Content(modifier = Modifier)
destinationBlockComponent.Content(modifier = Modifier)
feeSelectorBlockComponent.Content(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
} else {
TransactionDoneTitleAnimated(uiState)
destinationBlockComponent.Content(modifier = Modifier)
amountBlockComponent.Content(modifier = Modifier)
feeBlockComponent.Content(modifier = Modifier)
}
feeBlockComponent.Content(modifier = Modifier)
}
}
}
@Composable
internal fun TransactionDoneTitleAnimated(uiState: SendUM) {
AnimatedVisibility(
visible = uiState.confirmUM is ConfirmUM.Success,
modifier = Modifier.padding(vertical = 12.dp),
) {
val wrappedConfirmUM = remember(this) { uiState.confirmUM as ConfirmUM.Success }
TransactionDoneTitle(
title = resourceReference(R.string.sent_transaction_sent_title),
subtitle = resourceReference(
R.string.send_date_format,
wrappedList(
wrappedConfirmUM.transactionDate.toTimeFormat(DateTimeFormatters.dateFormatter),
wrappedConfirmUM.transactionDate.toTimeFormat(),
),
),
modifier = Modifier.padding(vertical = 12.dp),
)
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.send.v2.send.confirm.model.SendConfirmModel
import com.tangem.features.send.v2.send.model.SendModel
import com.tangem.features.send.v2.send.success.model.SendConfirmSuccessModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -23,4 +24,9 @@ internal interface CommonSendModelModule {
@IntoMap
@ClassKey(SendConfirmModel::class)
fun provideSendConfirmModel(model: SendConfirmModel): Model
@Binds
@IntoMap
@ClassKey(SendConfirmSuccessModel::class)
fun provideSendConfirmSuccessModel(model: SendConfirmSuccessModel): Model
}

View file

@ -39,16 +39,19 @@ import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.entity.PredefinedValues
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.features.send.v2.common.SendConfirmAlertFactory
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.send.confirm.SendConfirmComponent
import com.tangem.features.send.v2.send.success.SendConfirmSuccessComponent
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.v2.subcomponents.amount.SendAmountUpdateQRTrigger
import com.tangem.features.send.v2.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.SendDestinationInitialStateTransformer
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
@ -65,7 +68,8 @@ internal interface SendComponentCallback :
SendAmountComponent.ModelCallback,
SendFeeComponent.ModelCallback,
SendDestinationComponent.ModelCallback,
SendConfirmComponent.ModelCallback
SendConfirmComponent.ModelCallback,
SendConfirmSuccessComponent.ModelCallback
@Stable
@ModelScoped
@ -87,7 +91,7 @@ internal class SendModel @Inject constructor(
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendAmountUpdateQRTrigger: SendAmountUpdateQRTrigger,
private val sendAmountUpdateTrigger: SendAmountUpdateTrigger,
private val sendFeatureToggles: SendFeatureToggles,
) : Model(), SendComponentCallback {
@ -151,6 +155,15 @@ internal class SendModel @Inject constructor(
_uiState.update { sendUM }
}
override fun onConvertToAnotherToken(lastAmount: String) {
params.callback?.onConvertToAnotherToken(lastAmount = lastAmount)
}
override fun onError(error: GetUserWalletError) {
Timber.w(error.toString())
showAlertError()
}
suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
val predefinedValues = predefinedValues
val transferTransaction = if (predefinedValues is PredefinedValues.Content.Deeplink) {
@ -347,7 +360,7 @@ internal class SendModel @Inject constructor(
)
// If it is in active state use flow to update value in amount component
modelScope.launch {
amount?.let { sendAmountUpdateQRTrigger.triggerUpdateAmount(it) }
amount?.let { sendAmountUpdateTrigger.triggerUpdateAmount(it) }
}
}
@ -382,5 +395,7 @@ internal class SendModel @Inject constructor(
confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
isRedesignEnabled = sendFeatureToggles.isSendRedesignEnabled,
confirmData = null,
feeSelectorUM = FeeSelectorUM.Loading,
)
}

View file

@ -0,0 +1,51 @@
package com.tangem.features.send.v2.send.success
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.v2.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.send.success.model.SendConfirmSuccessModel
import com.tangem.features.send.v2.send.success.ui.SendConfirmSuccessContent
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal class SendConfirmSuccessComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendConfirmSuccessModel = getOrCreateModel(params = params)
private val destinationBlockComponent: SendDestinationBlockComponent = params.destinationBlockComponent
private val feeBlockComponent: SendFeeBlockComponent = params.feeBlockComponent
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsState()
SendConfirmSuccessContent(
sendUM = state,
destinationBlockComponent = destinationBlockComponent,
feeBlockComponent = feeBlockComponent,
)
}
data class Params(
val sendUMFlow: StateFlow<SendUM>,
val destinationBlockComponent: SendDestinationBlockComponent,
val feeBlockComponent: SendFeeBlockComponent,
val analyticsCategoryName: String,
val currentRoute: Flow<CommonSendRoute>,
val txUrl: String,
val callback: ModelCallback,
)
interface ModelCallback {
fun onResult(sendUM: SendUM)
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.features.send.v2.send.success.model
import androidx.compose.runtime.Stable
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.send.success.SendConfirmSuccessComponent
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@Stable
@ModelScoped
internal class SendConfirmSuccessModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
) : Model() {
private val params: SendConfirmSuccessComponent.Params = paramsContainer.require()
private val _uiState = params.sendUMFlow
val uiState = _uiState
init {
configConfirmSuccessNavigation()
}
private fun configConfirmSuccessNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { it.second is CommonSendRoute.ConfirmSuccess }.onEach { (state, _) ->
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
title = stringReference(""),
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
appRouter.pop()
},
primaryButton = NavigationButton(
textReference = resourceReference(R.string.common_close),
iconRes = null,
isEnabled = true,
isHapticClick = false,
onClick = {
appRouter.pop()
},
),
prevButton = null,
secondaryPairButtonsUM = NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = ::onExploreClick,
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = ::onShareClick,
),
),
),
)
}.launchIn(modelScope)
}
private fun onExploreClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName))
urlOpener.openUrl(params.txUrl)
}
private fun onShareClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName))
shareManager.shareText(params.txUrl)
}
interface ModelCallback {
fun onResult(sendUM: SendUM)
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.send.v2.send.success.ui
import androidx.compose.animation.*
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toPx
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.v2.common.ui.SendNavigationButtons
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent
import kotlinx.coroutines.delay
@Composable
internal fun SendConfirmSuccessContent(
sendUM: SendUM,
destinationBlockComponent: SendDestinationBlockComponent,
feeBlockComponent: SendFeeBlockComponent,
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(ANIMATION_DELAY)
visible = true
}
val height = ANIMATION_OFFSET.toPx().toInt()
AnimatedVisibility(
visible = visible,
enter = slideInVertically(
initialOffsetY = { height },
).plus(fadeIn()),
exit = slideOutVertically().plus(fadeOut()),
label = "Animate success content",
) {
Column {
Column(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.scrollable(
state = rememberScrollState(),
orientation = Orientation.Horizontal,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (sendUM.confirmUM is ConfirmUM.Success) {
TransactionDoneTitle(
title = resourceReference(R.string.sent_transaction_sent_title),
subtitle = resourceReference(
R.string.send_date_format,
wrappedList(
sendUM.confirmUM.transactionDate.toTimeFormat(DateTimeFormatters.dateFormatter),
sendUM.confirmUM.transactionDate.toTimeFormat(),
),
),
modifier = Modifier.padding(vertical = 12.dp),
)
}
AmountBlock(
amountState = sendUM.amountUM,
isClickDisabled = true,
isEditingDisabled = true,
onClick = {},
)
destinationBlockComponent.Content(modifier = Modifier)
feeBlockComponent.Content(modifier = Modifier)
}
SpacerHMax()
SendNavigationButtons(navigationUM = sendUM.navigationUM)
}
}
}
private const val ANIMATION_DELAY = 600L
private val ANIMATION_OFFSET = (-40).dp

View file

@ -2,15 +2,19 @@ package com.tangem.features.send.v2.send.ui.state
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.send.confirm.model.ConfirmData
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
internal data class SendUM(
val amountUM: AmountState,
val destinationUM: DestinationUM,
val feeUM: FeeUM,
val feeSelectorUM: FeeSelectorUM,
val confirmUM: ConfirmUM,
val navigationUM: NavigationUM,
val isRedesignEnabled: Boolean,
val confirmData: ConfirmData?,
)

View file

@ -30,6 +30,8 @@ import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.features.nft.entity.NFTSendSuccessTrigger
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.SendBalanceUpdater
import com.tangem.features.send.v2.common.SendConfirmAlertFactory
@ -47,7 +49,6 @@ import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.stripZeroPlainString
import com.tangem.utils.transformer.update
@ -72,7 +73,8 @@ internal class NFTSendConfirmModel @Inject constructor(
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val notificationsUpdateTrigger: NotificationsUpdateTrigger,
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val notificationsUpdateListener: SendNotificationsUpdateListener,
private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger,
private val sendFeeCheckReloadListener: SendFeeCheckReloadListener,
private val alertFactory: SendConfirmAlertFactory,
@ -242,7 +244,7 @@ internal class NFTSendConfirmModel @Inject constructor(
}
private fun subscribeOnNotificationsUpdateTrigger() {
notificationsUpdateTrigger.hasErrorFlow
notificationsUpdateListener.hasErrorFlow
.onEach { hasError ->
_uiState.update {
val feeUM = it.feeUM as? FeeUM.Content

View file

@ -20,9 +20,7 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase
@ -61,6 +59,8 @@ internal class NFTSendModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val tokensFeatureToggles: TokensFeatureToggles,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val createNFTTransferTransactionUseCase: CreateNFTTransferTransactionUseCase,
@ -146,9 +146,16 @@ internal class NFTSendModel @Inject constructor(
ifRight = { wallet ->
userWallet = wallet
cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId).getOrNull()
?.filterIsInstance<CryptoCurrency.Coin>()
?.firstOrNull { it.network == nftAsset.network }
cryptoCurrency = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId),
)
?.firstOrNull { it is CryptoCurrency.Coin && it.network == nftAsset.network }
} else {
getCryptoCurrenciesUseCase(userWalletId).getOrNull()
?.filterIsInstance<CryptoCurrency.Coin>()
?.firstOrNull { it.network == nftAsset.network }
}
?: return@launch
getCurrenciesStatusUpdates(

View file

@ -1,19 +1,18 @@
package com.tangem.features.send.v2.subcomponents.amount
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.amountScreen.AmountScreenContent
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.core.ui.res.TangemTheme
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams.AmountParams
import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel
import com.tangem.features.send.v2.subcomponents.amount.ui.SendAmountContent
internal class SendAmountComponent(
appComponentContext: AppComponentContext,
@ -29,15 +28,18 @@ internal class SendAmountComponent(
val state by model.uiState.collectAsStateWithLifecycle()
val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle()
AmountScreenContent(
SendAmountContent(
amountState = state,
isBalanceHidden = isBalanceHidden,
clickIntents = model,
modifier = Modifier.background(TangemTheme.colors.background.tertiary),
isSendWithSwapEnabled = model.isSendWithSwapEnabled,
modifier = modifier,
)
}
interface ModelCallback : NavigationModelCallback {
fun onAmountResult(amountUM: AmountState, isResetPredefined: Boolean)
fun onConvertToAnotherToken(lastAmount: String)
fun onError(error: GetUserWalletError)
}
}

View file

@ -29,7 +29,7 @@ interface SendAmountReduceListener {
* Trigger amount change from another component.
* Different from another triggers because it takes raw string instead of BigDecimal
*/
interface SendAmountUpdateQRTrigger {
interface SendAmountUpdateTrigger {
suspend fun triggerUpdateAmount(amountValue: String)
}
@ -37,7 +37,7 @@ interface SendAmountUpdateQRTrigger {
* Trigger amount change from another component.
* Different from another triggers because it takes raw string instead of BigDecimal
*/
interface SendAmountUpdateQRListener {
interface SendAmountUpdateListener {
val updateAmountTriggerFlow: Flow<String>
}
@ -45,8 +45,8 @@ interface SendAmountUpdateQRListener {
internal class DefaultSendAmountReduceTrigger @Inject constructor() :
SendAmountReduceTrigger,
SendAmountReduceListener,
SendAmountUpdateQRTrigger,
SendAmountUpdateQRListener {
SendAmountUpdateTrigger,
SendAmountUpdateListener {
override val reduceToTriggerFlow = MutableSharedFlow<BigDecimal>()
override val reduceByTriggerFlow = MutableSharedFlow<ReduceByData>()

View file

@ -1,7 +1,6 @@
package com.tangem.features.send.v2.subcomponents.amount.di
import com.tangem.features.send.v2.subcomponents.amount.*
import com.tangem.features.send.v2.subcomponents.amount.DefaultSendAmountReduceTrigger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -22,9 +21,9 @@ internal interface SendAmountModule {
@Singleton
@Binds
fun provideSendAmountUpdateQRListener(impl: DefaultSendAmountReduceTrigger): SendAmountUpdateQRListener
fun provideSendAmountUpdateListener(impl: DefaultSendAmountReduceTrigger): SendAmountUpdateListener
@Singleton
@Binds
fun provideSendAmountUpdateQRTrigger(impl: DefaultSendAmountReduceTrigger): SendAmountUpdateQRTrigger
fun provideSendAmountUpdateTrigger(impl: DefaultSendAmountReduceTrigger): SendAmountUpdateTrigger
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.send.v2.subcomponents.amount.model
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
interface SendAmountClickIntents : AmountScreenClickIntents {
fun onConvertToAnotherToken()
}

View file

@ -2,7 +2,6 @@ package com.tangem.features.send.v2.subcomponents.amount.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.converters.*
import com.tangem.common.ui.amountScreen.converters.field.AmountBoundaryUpdateTransformer
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer
@ -31,7 +30,7 @@ import com.tangem.features.send.v2.api.entity.PredefinedValues
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.v2.subcomponents.amount.SendAmountUpdateQRListener
import com.tangem.features.send.v2.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.v2.subcomponents.amount.analytics.SendAmountAnalyticEvents
import com.tangem.features.send.v2.subcomponents.amount.analytics.SendAmountAnalyticEvents.SelectedCurrencyType
import com.tangem.features.send.v2.subcomponents.fee.SendFeeData
@ -54,12 +53,12 @@ internal class SendAmountModel @Inject constructor(
private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase,
private val sendAmountReduceListener: SendAmountReduceListener,
private val feeReloadTrigger: SendFeeReloadTrigger,
private val sendAmountUpdateQRListener: SendAmountUpdateQRListener,
private val sendAmountUpdateListener: SendAmountUpdateListener,
private val analyticsEventHandler: AnalyticsEventHandler,
private val sendFeatureToggles: SendFeatureToggles,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
) : Model(), AmountScreenClickIntents {
) : Model(), SendAmountClickIntents {
private val params: SendAmountComponentParams = paramsContainer.require()
private var appCurrency: AppCurrency = AppCurrency.Default
@ -68,6 +67,8 @@ internal class SendAmountModel @Inject constructor(
private val _uiState = MutableStateFlow(params.state)
val uiState = _uiState.asStateFlow()
val isSendWithSwapEnabled = sendFeatureToggles.isSendWithSwapEnabled
private val analyticsCategoryName = params.analyticsCategoryName
private var cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = params.cryptoCurrency,
@ -84,14 +85,22 @@ internal class SendAmountModel @Inject constructor(
subscribeOnAmountReduceByTriggerUpdates()
subscribeOnAmountReduceToTriggerUpdates()
subscribeOnAmountIgnoreReduceTriggerUpdates()
subscribeOnAmountUpdateQRTriggerUpdates()
subscribeOnAmountUpdateTriggerUpdates()
}
private fun initAppCurrency() {
modelScope.launch {
userWallet = getUserWalletUseCase(params.userWalletId).getOrNull()
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
}
getUserWalletUseCase.invokeFlow(params.userWalletId)
.onEach { either ->
either.fold(
ifLeft = { error ->
val amountParams = params as? SendAmountComponentParams.AmountParams
amountParams?.callback?.onError(error)
},
ifRight = { wallet ->
userWallet = wallet
},
)
}.launchIn(modelScope)
}
private fun subscribeOnCryptoCurrencyStatusFlow() {
@ -116,6 +125,8 @@ internal class SendAmountModel @Inject constructor(
)
}
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
if (uiState.value is AmountState.Data) {
_uiState.update(
AmountBoundaryUpdateTransformer(
@ -215,6 +226,12 @@ internal class SendAmountModel @Inject constructor(
saveResult()
}
override fun onConvertToAnotherToken() {
val amountFieldData = uiState.value as? AmountState.Data
val amountParams = params as? SendAmountComponentParams.AmountParams
amountParams?.callback?.onConvertToAnotherToken(amountFieldData?.amountTextField?.value.orEmpty())
}
private fun subscribeOnAmountReduceToTriggerUpdates() {
sendAmountReduceListener.reduceToTriggerFlow
.onEach { reduceTo ->
@ -259,8 +276,8 @@ internal class SendAmountModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeOnAmountUpdateQRTriggerUpdates() {
sendAmountUpdateQRListener.updateAmountTriggerFlow
private fun subscribeOnAmountUpdateTriggerUpdates() {
sendAmountUpdateListener.updateAmountTriggerFlow
.onEach { amount ->
onAmountValueChange(amount)
saveResult()

View file

@ -0,0 +1,109 @@
package com.tangem.features.send.v2.subcomponents.amount.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.amountScreen.AmountScreenContent
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountClickIntents
import com.tangem.features.send.v2.subcomponents.amount.ui.preview.SendAmountClickIntentsStub
@Composable
fun SendAmountContent(
amountState: AmountState,
isBalanceHidden: Boolean,
clickIntents: SendAmountClickIntents,
isSendWithSwapEnabled: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.background(TangemTheme.colors.background.tertiary)) {
AmountScreenContent(
amountState = amountState,
isBalanceHidden = isBalanceHidden,
clickIntents = clickIntents,
)
if (isSendWithSwapEnabled) {
SendConvertTokenButton(
onConvertToAnother = clickIntents::onConvertToAnotherToken,
)
}
}
}
@Composable
private fun SendConvertTokenButton(onConvertToAnother: () -> Unit) {
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = null,
onClick = onConvertToAnother,
),
) {
Row(
modifier = Modifier
.padding(12.dp)
.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_convert_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(20.dp)
.background(TangemTheme.colors.control.unchecked, CircleShape)
.padding(2.dp),
)
Text(
text = stringResourceSafe(com.tangem.common.ui.R.string.send_amount_convert_to_another_token),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SendAmountContent_Preview(@PreviewParameter(SendAmountContentPreviewProvider::class) params: AmountState) {
TangemThemePreview {
SendAmountContent(
amountState = params,
isBalanceHidden = true,
clickIntents = SendAmountClickIntentsStub,
isSendWithSwapEnabled = true,
)
}
}
private class SendAmountContentPreviewProvider : PreviewParameterProvider<AmountState> {
override val values: Sequence<AmountState>
get() = sequenceOf(
AmountStatePreviewData.amountStateV2,
)
}
// endregion

View file

@ -0,0 +1,17 @@
package com.tangem.features.send.v2.subcomponents.amount.ui.preview
import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountClickIntents
internal object SendAmountClickIntentsStub : SendAmountClickIntents {
override fun onConvertToAnotherToken() {}
override fun onAmountValueChange(value: String) {}
override fun onAmountPasteTriggerDismiss() {}
override fun onMaxValueClick() {}
override fun onCurrencyChangeClick(isFiat: Boolean) {}
override fun onAmountNext() {}
}

View file

@ -21,8 +21,8 @@ import kotlinx.coroutines.flow.onEach
internal class DefaultSendDestinationBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendDestinationComponentParams.DestinationBlockParams,
val onClick: () -> Unit,
val onResult: (DestinationUM) -> Unit,
@Assisted val onClick: () -> Unit,
@Assisted val onResult: (DestinationUM) -> Unit,
) : SendDestinationBlockComponent, AppComponentContext by appComponentContext {
private val model: SendDestinationModel = getOrCreateModel(params = params)
@ -33,7 +33,7 @@ internal class DefaultSendDestinationBlockComponent @AssistedInject constructor(
}.launchIn(componentScope)
}
fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM)
override fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM)
@Composable
override fun Content(modifier: Modifier) {
@ -54,6 +54,8 @@ internal class DefaultSendDestinationBlockComponent @AssistedInject constructor(
override fun create(
context: AppComponentContext,
params: SendDestinationComponentParams.DestinationBlockParams,
onClick: () -> Unit,
onResult: (DestinationUM) -> Unit,
): DefaultSendDestinationBlockComponent
}
}

View file

@ -22,7 +22,7 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor(
private val model: SendDestinationModel = getOrCreateModel(params = params)
fun updateState(state: DestinationUM) = model.updateState(state)
override fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM)
@Composable
override fun Content(modifier: Modifier) {

View file

@ -347,7 +347,7 @@ internal class SendDestinationModel @Inject constructor(
params.onNextClick()
},
),
prevButton = if (!route.isEditMode && isRedesignEnabled) {
prevButton = if (!route.isEditMode && !isRedesignEnabled) {
NavigationButton(
textReference = TextReference.EMPTY,
iconRes = R.drawable.ic_back_24,

View file

@ -11,10 +11,10 @@ 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.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.lib.crypto.BlockchainUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -24,7 +24,7 @@ import java.math.RoundingMode
internal class BitcoinCustomFeeConverter(
private val onCustomFeeValueChange: (Int, String) -> Unit,
private val onNextClick: () -> Unit,
private val onNextClick: (() -> Unit)?,
private val appCurrency: AppCurrency,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : CustomFeeConverter<Fee.Bitcoin> {
@ -78,7 +78,13 @@ internal class BitcoinCustomFeeConverter(
},
keyboardType = KeyboardType.Companion.Number,
),
keyboardActions = KeyboardActions(onDone = { onNextClick() }),
keyboardActions = KeyboardActions(
onDone = if (onNextClick != null) {
{ onNextClick() }
} else {
null
},
),
),
)
} else {

View file

@ -18,7 +18,7 @@ import kotlinx.collections.immutable.toImmutableList
internal class EthereumCustomFeeConverter(
private val onCustomFeeValueChange: (Int, String) -> Unit,
private val onNextClick: () -> Unit,
private val onNextClick: (() -> Unit)?,
private val appCurrency: AppCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : BaseEthereumCustomFeeConverter<Fee.Ethereum> {
@ -112,7 +112,11 @@ internal class EthereumCustomFeeConverter(
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(
onDone = { onNextClick() },
onDone = if (onNextClick != null) {
{ onNextClick() }
} else {
null
},
),
)
}

View file

@ -1,28 +1,17 @@
package com.tangem.features.send.v2.subcomponents.notifications
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import kotlinx.coroutines.flow.Flow
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
interface NotificationsUpdateTrigger {
/** Flow triggers notifications update */
val updateTriggerFlow: Flow<NotificationData>
/** Flow returns whether there is error notifications */
val hasErrorFlow: Flow<Boolean>
/** Trigger return callback with check result */
suspend fun callbackHasError(hasError: Boolean)
/** Trigger fee check reload */
suspend fun triggerUpdate(data: NotificationData)
}
@Singleton
internal class DefaultNotificationsUpdateTrigger @Inject constructor() : NotificationsUpdateTrigger {
internal class DefaultNotificationsUpdateTrigger @Inject constructor() :
SendNotificationsUpdateListener,
SendNotificationsUpdateTrigger {
private val _updateTriggerFlow = MutableSharedFlow<NotificationData>()
override val updateTriggerFlow = _updateTriggerFlow.asSharedFlow()

View file

@ -1,20 +1,23 @@
package com.tangem.features.send.v2.subcomponents.notifications.di
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.subcomponents.notifications.DefaultNotificationsUpdateTrigger
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
internal object NotificationsModule {
internal interface NotificationsModule {
@Provides
@Singleton
fun providesNotificationsUpdateTrigger(): NotificationsUpdateTrigger {
return DefaultNotificationsUpdateTrigger()
}
@Binds
fun bindsNotificationsUpdateTrigger(impl: DefaultNotificationsUpdateTrigger): SendNotificationsUpdateTrigger
@Singleton
@Binds
fun bindsNotificationsUpdateListener(impl: DefaultNotificationsUpdateTrigger): SendNotificationsUpdateListener
}

View file

@ -37,12 +37,13 @@ import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.v2.subcomponents.fee.SendFeeData
import com.tangem.features.send.v2.subcomponents.fee.SendFeeReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.model.checkAndCalculateSubtractedAmount
import com.tangem.features.send.v2.subcomponents.fee.model.checkFeeCoverage
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import com.tangem.features.send.v2.subcomponents.notifications.analytics.NotificationsAnalyticEvents
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.isTron
@ -74,7 +75,8 @@ internal class NotificationsModel @Inject constructor(
private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase,
private val sendFeeReloadTrigger: SendFeeReloadTrigger,
private val sendAmountReduceTrigger: SendAmountReduceTrigger,
private val notificationsUpdateTrigger: NotificationsUpdateTrigger,
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val notificationsUpdateListener: SendNotificationsUpdateListener,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
@ -101,7 +103,7 @@ internal class NotificationsModel @Inject constructor(
}
private fun subscribeToNotificationUpdateTrigger() {
notificationsUpdateTrigger.updateTriggerFlow
notificationsUpdateListener.updateTriggerFlow
.onEach { updateState(it) }
.launchIn(modelScope)
}

View file

@ -0,0 +1,439 @@
package com.tangem.features.send.v2.send.confirm.model.transformers
import com.google.common.truth.Truth.assertThat
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.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import java.math.BigInteger
import java.util.Locale
import org.junit.jupiter.api.BeforeAll
class SendConfirmationNotificationsTransformerTest {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true)
private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$")
private val analyticsCategoryName = "test_category"
@Test
fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest {
// GIVEN
val feeUM: FeeUM = mockk(relaxed = true)
val amountUM: AmountState = mockk(relaxed = true)
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState: ConfirmUM = ConfirmUM.Empty
// WHEN
val result = transformer.transform(initialState)
// THEN
assertThat(result).isEqualTo(initialState)
}
@Test
fun `GIVEN fee UM not content WHEN transform THEN returns original state`() = runTest {
// GIVEN
val feeUM: FeeUM = FeeUM.Empty()
val amountUM: AmountState = mockk(relaxed = true)
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState: ConfirmUM.Content = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
assertThat(result).isEqualTo(initialState)
}
@Test
fun `GIVEN normal fee WHEN transform THEN returns state with footer and no notifications`() = runTest {
// GIVEN
val feeUM = createNormalFeeUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
val content = result as ConfirmUM.Content
assertThat(content.notifications).isEmpty()
assertThat(content.sendingFooter).isNotEqualTo(initialState.sendingFooter)
}
@Test
fun `GIVEN fee too high WHEN transform THEN returns state with too high notification`() = runTest {
// GIVEN
val feeUM = createFeeTooHighUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
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)
}
@Test
fun `GIVEN fee too low WHEN transform THEN returns state with too low notification`() = runTest {
// GIVEN
val feeUM = createFeeTooLowUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
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)
verify { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN both fee too high and too low WHEN transform THEN returns state with both notifications`() = runTest {
// GIVEN
val feeUM = createFeeTooHighAndTooLowUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
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()
}
private fun createTestConfirmUM(): ConfirmUM.Content {
return ConfirmUM.Content(
isPrimaryButtonEnabled = true,
walletName = mockk(relaxed = true),
isSending = false,
showTapHelp = false,
sendingFooter = mockk(relaxed = true),
notifications = persistentListOf(),
)
}
private fun createTestAmountUM(): AmountState.Data {
val cryptoAmount = com.tangem.domain.tokens.model.Amount(
currencySymbol = "SOL",
value = BigDecimal("1.5"),
decimals = 8,
)
val fiatAmount = com.tangem.domain.tokens.model.Amount(
currencySymbol = "USD",
value = BigDecimal("50.00"),
decimals = 2,
)
return AmountState.Data(
isPrimaryButtonEnabled = true,
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),
selectedButton = 0,
isSegmentedButtonsEnabled = false,
amountTextField = AmountFieldModel(
value = "1.5",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
cryptoAmount = cryptoAmount,
fiatAmount = fiatAmount,
isFiatValue = false,
fiatValue = "50.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = mockk(relaxed = true),
),
appCurrency = appCurrency,
isEditingDisabled = false,
reduceAmountBy = BigDecimal.ZERO,
isIgnoreReduce = false,
)
}
private fun createNormalFeeUM(): FeeUM.Content {
val fee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Single(fee)
return FeeUM.Content(
feeSelectorUM = FeeSelectorUM.Content(
fees = transactionFee,
selectedType = FeeType.Market,
selectedFee = fee,
customValues = persistentListOf(),
nonce = BigInteger.ZERO,
),
rate = BigDecimal("50000"),
isFeeConvertibleToFiat = true,
isFeeApproximate = false,
isTronToken = false,
isEditingDisabled = false,
isPrimaryButtonEnabled = true,
appCurrency = AppCurrency.Default,
isCustomSelected = false,
notifications = persistentListOf(),
)
}
private fun createFeeTooHighUM(): FeeUM.Content {
val priorityFee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val customFee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.01"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = minimumFee,
priority = priorityFee,
)
return FeeUM.Content(
feeSelectorUM = FeeSelectorUM.Content(
fees = transactionFee,
selectedType = FeeType.Custom,
selectedFee = customFee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.01",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "SOL",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
nonce = BigInteger.ZERO,
),
rate = BigDecimal("50000"),
isFeeConvertibleToFiat = true,
isFeeApproximate = false,
isTronToken = false,
isEditingDisabled = false,
isPrimaryButtonEnabled = true,
appCurrency = AppCurrency.Default,
isCustomSelected = false,
notifications = persistentListOf(),
)
}
private fun createFeeTooLowUM(): FeeUM.Content {
val fee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.0001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = fee,
priority = fee,
)
return FeeUM.Content(
feeSelectorUM = FeeSelectorUM.Content(
fees = transactionFee,
selectedType = FeeType.Custom,
selectedFee = fee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.0001",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "SOL",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
nonce = BigInteger.ZERO,
),
rate = BigDecimal("50000"),
isFeeConvertibleToFiat = true,
isFeeApproximate = false,
isTronToken = false,
isEditingDisabled = false,
isPrimaryButtonEnabled = true,
appCurrency = AppCurrency.Default,
isCustomSelected = false,
notifications = persistentListOf(),
)
}
private fun createFeeTooHighAndTooLowUM(): FeeUM.Content {
val priorityFee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.01"),
decimals = 8,
),
)
val customFee = Fee.Common(
amount = com.tangem.blockchain.common.Amount(
currencySymbol = "SOL",
value = BigDecimal("0.008"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = minimumFee,
priority = priorityFee,
)
return FeeUM.Content(
feeSelectorUM = FeeSelectorUM.Content(
fees = transactionFee,
selectedType = FeeType.Custom,
selectedFee = customFee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.008",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "SOL",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
nonce = BigInteger.ZERO,
),
rate = BigDecimal("50000"),
isFeeConvertibleToFiat = true,
isFeeApproximate = false,
isTronToken = false,
isEditingDisabled = false,
isPrimaryButtonEnabled = true,
appCurrency = AppCurrency.Default,
isCustomSelected = false,
notifications = persistentListOf(),
)
}
companion object {
@JvmStatic
@BeforeAll
fun setUpLocale() {
Locale.setDefault(Locale.US)
}
}
}

View file

@ -0,0 +1,505 @@
package com.tangem.features.send.v2.send.confirm.model.transformers
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.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeAll
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 {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true)
private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$")
private val analyticsCategoryName = "test_category"
@Test
fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest {
// GIVEN
val feeSelectorUM: FeeSelectorUM = mockk(relaxed = true)
val amountUM: AmountState = mockk(relaxed = true)
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState: ConfirmUM = ConfirmUM.Empty
// WHEN
val result = transformer.transform(initialState)
// THEN
assertThat(result).isEqualTo(initialState)
}
@Test
fun `GIVEN fee selector not content WHEN transform THEN returns original state`() = runTest {
// GIVEN
val feeSelectorUM: FeeSelectorUM = FeeSelectorUM.Loading
val amountUM: AmountState = mockk(relaxed = true)
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
assertThat(result).isEqualTo(initialState)
}
@Test
fun `GIVEN normal fee WHEN transform THEN returns state with footer and no notifications`() = runTest {
// GIVEN
val feeSelectorUM = createNormalFeeSelectorUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
assertThat(result).isInstanceOf(ConfirmUM.Content::class.java)
val content = result as ConfirmUM.Content
assertThat(content.notifications).isEmpty()
assertThat(content.sendingFooter).isNotEqualTo(initialState.sendingFooter)
}
@Test
fun `GIVEN fee too high WHEN transform THEN returns state with too high notification`() = runTest {
// GIVEN
val feeSelectorUM = createFeeTooHighUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
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)
}
@Test
fun `GIVEN fee too low WHEN transform THEN returns state with too low notification`() = runTest {
// GIVEN
val feeSelectorUM = createFeeTooLowUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
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)
verify { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN both fee too high and too low WHEN transform THEN returns state with both notifications`() = runTest {
// GIVEN
val feeSelectorUM = createFeeTooHighAndTooLowUM()
val amountUM = createTestAmountUM()
val transformer = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val initialState = createTestConfirmUM()
// WHEN
val result = transformer.transform(initialState)
// THEN
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()
}
private fun createTestConfirmUM(): ConfirmUM.Content {
return ConfirmUM.Content(
isPrimaryButtonEnabled = true,
walletName = mockk(relaxed = true),
isSending = false,
showTapHelp = false,
sendingFooter = mockk(relaxed = true),
notifications = persistentListOf(),
)
}
private fun createTestAmountUM(): AmountState.Data {
val cryptoAmount = DomainAmount(
currencySymbol = "SOL",
value = BigDecimal("1.5"),
decimals = 8,
)
val fiatAmount = DomainAmount(
currencySymbol = "USD",
value = BigDecimal("50.00"),
decimals = 2,
)
return AmountState.Data(
isPrimaryButtonEnabled = true,
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),
selectedButton = 0,
isSegmentedButtonsEnabled = false,
amountTextField = AmountFieldModel(
value = "1.5",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
cryptoAmount = cryptoAmount,
fiatAmount = fiatAmount,
isFiatValue = false,
fiatValue = "50.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = mockk(relaxed = true),
),
appCurrency = appCurrency,
isEditingDisabled = false,
reduceAmountBy = BigDecimal.ZERO,
isIgnoreReduce = false,
)
}
private fun createNormalFeeSelectorUM(): FeeSelectorUM.Content {
val fee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Single(fee)
return FeeSelectorUM.Content(
fees = transactionFee,
feeItems = persistentListOf(FeeItem.Market(fee)),
selectedFeeItem = FeeItem.Market(fee),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
appCurrency = appCurrency,
),
feeNonce = FeeNonce.Nonce(
nonce = BigInteger.ZERO,
onNonceChange = {},
),
)
}
private fun createFeeTooHighUM(): FeeSelectorUM.Content {
val priorityFee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = minimumFee,
priority = priorityFee,
)
return FeeSelectorUM.Content(
fees = transactionFee,
feeItems = persistentListOf(
FeeItem.Custom(
fee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.01"),
decimals = 8,
),
),
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.01",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "SOL",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
),
selectedFeeItem = FeeItem.Custom(
fee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.01"),
decimals = 8,
),
),
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.01",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "SOL",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
appCurrency = appCurrency,
),
feeNonce = FeeNonce.Nonce(
nonce = BigInteger.ZERO,
onNonceChange = {},
),
)
}
private fun createFeeTooLowUM(): FeeSelectorUM.Content {
val fee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.0001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = fee,
priority = fee,
)
return FeeSelectorUM.Content(
fees = transactionFee,
feeItems = persistentListOf(
FeeItem.Custom(
fee = fee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.0001",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "SOL",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
),
selectedFeeItem = FeeItem.Custom(
fee = fee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.0001",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "SOL",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
appCurrency = appCurrency,
),
feeNonce = FeeNonce.Nonce(
nonce = BigInteger.ZERO,
onNonceChange = {},
),
)
}
private fun createFeeTooHighAndTooLowUM(): FeeSelectorUM.Content {
val priorityFee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.01"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = minimumFee,
priority = priorityFee,
)
return FeeSelectorUM.Content(
fees = transactionFee,
feeItems = persistentListOf(
FeeItem.Custom(
fee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.008"),
decimals = 8,
),
),
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.008",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "SOL",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
),
selectedFeeItem = FeeItem.Custom(
fee = Fee.Common(
amount = Amount(
currencySymbol = "SOL",
value = BigDecimal("0.008"),
decimals = 8,
),
),
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.008",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "SOL",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
appCurrency = appCurrency,
),
feeNonce = FeeNonce.Nonce(
nonce = BigInteger.ZERO,
onNonceChange = {},
),
)
}
companion object {
@JvmStatic
@BeforeAll
fun setUpLocale() {
Locale.setDefault(Locale.US)
}
}
}

View file

@ -0,0 +1,870 @@
package com.tangem.features.send.v2.send.confirm.model.transformers
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.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeAll
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
import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMV2
class TransformersComparisonTest {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true)
private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$")
private val analyticsCategoryName = "test_category"
@Test
fun `GIVEN equivalent input data WHEN both transformers transform THEN they produce equal ConfirmUM`() = runTest {
// GIVEN
val initialConfirmUM = createTestConfirmUM()
val amountUM = createTestAmountUM()
val feeUM = createTestFeeUM()
val feeSelectorUMV2 = createTestFeeSelectorUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val resultV1 = transformerV1.transform(initialConfirmUM)
val resultV2 = transformerV2.transform(initialConfirmUM)
// THEN
assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java)
assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java)
val contentV1 = resultV1 as ConfirmUM.Content
val contentV2 = resultV2 as ConfirmUM.Content
assertThat(contentV1.notifications.size).isEqualTo(contentV2.notifications.size)
assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter)
assertThat(contentV1.isPrimaryButtonEnabled).isEqualTo(contentV2.isPrimaryButtonEnabled)
assertThat(contentV1.isSending).isEqualTo(contentV2.isSending)
assertThat(contentV1.showTapHelp).isEqualTo(contentV2.showTapHelp)
}
@Test
fun `GIVEN fee too low WHEN both transformers transform THEN they produce equal notifications`() = runTest {
// GIVEN
val initialConfirmUM = createTestConfirmUM()
val amountUM = createTestAmountUM()
val feeUM = createFeeTooLowUM()
val feeSelectorUMV2 = createFeeTooLowUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val resultV1 = transformerV1.transform(initialConfirmUM)
val resultV2 = transformerV2.transform(initialConfirmUM)
// THEN
assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java)
assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java)
val contentV1 = resultV1 as ConfirmUM.Content
val contentV2 = resultV2 as ConfirmUM.Content
assertThat(contentV1.notifications).hasSize(1)
assertThat(contentV2.notifications).hasSize(1)
assertThat(contentV1.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java)
assertThat(contentV2.notifications.first()).isInstanceOf(NotificationUM.Warning.FeeTooLow::class.java)
assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter)
// Verify analytics event was sent for both transformers
verify(exactly = 2) { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN fee too high WHEN both transformers transform THEN they produce equal notifications`() = runTest {
// GIVEN
val initialConfirmUM = createTestConfirmUM()
val amountUM = createTestAmountUM()
val feeUM = createFeeTooHighUM()
val feeSelectorUMV2 = createFeeTooHighUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val resultV1 = transformerV1.transform(initialConfirmUM)
val resultV2 = transformerV2.transform(initialConfirmUM)
// THEN
assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java)
assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java)
val contentV1 = resultV1 as ConfirmUM.Content
val contentV2 = resultV2 as ConfirmUM.Content
assertThat(contentV1.notifications).hasSize(1)
assertThat(contentV2.notifications).hasSize(1)
assertThat(contentV1.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java)
assertThat(contentV2.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java)
val tooHighV1 = contentV1.notifications.first() as NotificationUM.Warning.TooHigh
val tooHighV2 = contentV2.notifications.first() as NotificationUM.Warning.TooHigh
assertThat(tooHighV1.value).isEqualTo(tooHighV2.value)
assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter)
}
@Test
fun `GIVEN fee both too high and too low WHEN both transformers transform THEN they produce equal notifications`() =
runTest {
// GIVEN
val initialConfirmUM = createTestConfirmUM()
val amountUM = createTestAmountUM()
val feeUM = createFeeTooHighAndTooLowUM()
val feeSelectorUMV2 = createFeeTooHighAndTooLowUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val resultV1 = transformerV1.transform(initialConfirmUM)
val resultV2 = transformerV2.transform(initialConfirmUM)
// THEN
assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java)
assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java)
val contentV1 = resultV1 as ConfirmUM.Content
val contentV2 = resultV2 as ConfirmUM.Content
assertThat(contentV1.notifications).hasSize(2)
assertThat(contentV2.notifications).hasSize(2)
assertThat(contentV1.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue()
assertThat(contentV1.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue()
assertThat(contentV2.notifications.any { it is NotificationUM.Warning.FeeTooLow }).isTrue()
assertThat(contentV2.notifications.any { it is NotificationUM.Warning.TooHigh }).isTrue()
assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter)
verify(exactly = 2) { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN normal fee WHEN both transformers transform THEN they produce equal notifications`() = runTest {
// GIVEN
val initialConfirmUM = createTestConfirmUM()
val amountUM = createTestAmountUM()
val feeUM = createNormalFeeUM()
val feeSelectorUMV2 = createNormalFeeSelectorUMV2()
// WHEN
val transformerV1 = SendConfirmationNotificationsTransformer(
feeUM = feeUM,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val transformerV2 = SendConfirmationNotificationsTransformerV2(
feeSelectorUM = feeSelectorUMV2,
amountUM = amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrency,
appCurrency = appCurrency,
analyticsCategoryName = analyticsCategoryName,
)
val resultV1 = transformerV1.transform(initialConfirmUM)
val resultV2 = transformerV2.transform(initialConfirmUM)
// THEN
assertThat(resultV1).isInstanceOf(ConfirmUM.Content::class.java)
assertThat(resultV2).isInstanceOf(ConfirmUM.Content::class.java)
val contentV1 = resultV1 as ConfirmUM.Content
val contentV2 = resultV2 as ConfirmUM.Content
assertThat(contentV1.notifications).isEmpty()
assertThat(contentV2.notifications).isEmpty()
assertThat(contentV1.sendingFooter).isEqualTo(contentV2.sendingFooter)
}
private fun createTestConfirmUM(): ConfirmUM.Content {
return ConfirmUM.Content(
isPrimaryButtonEnabled = true,
walletName = mockk(relaxed = true),
isSending = false,
showTapHelp = false,
sendingFooter = mockk(relaxed = true),
notifications = persistentListOf(),
)
}
private fun createTestAmountUM(): AmountState.Data {
val cryptoAmount = DomainAmount(
currencySymbol = "TST",
value = BigDecimal("1.5"),
decimals = 8,
)
val fiatAmount = DomainAmount(
currencySymbol = "USD",
value = BigDecimal("50.00"),
decimals = 2,
)
return AmountState.Data(
isPrimaryButtonEnabled = true,
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),
selectedButton = 0,
isSegmentedButtonsEnabled = false,
amountTextField = AmountFieldModel(
value = "1.5",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
cryptoAmount = cryptoAmount,
fiatAmount = fiatAmount,
isFiatValue = false,
fiatValue = "50.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = mockk(relaxed = true),
),
appCurrency = appCurrency,
isEditingDisabled = false,
reduceAmountBy = BigDecimal.ZERO,
isIgnoreReduce = false,
)
}
private fun createTestFeeUM(): FeeUM.Content {
val fee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Single(fee)
return FeeUM.Content(
feeSelectorUM = FeeSelectorUM.Content(
fees = transactionFee,
selectedType = FeeType.Market,
selectedFee = fee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.001",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
nonce = BigInteger.ZERO,
),
rate = BigDecimal("50000"),
isFeeConvertibleToFiat = true,
isFeeApproximate = false,
isTronToken = false,
isEditingDisabled = false,
isPrimaryButtonEnabled = true,
appCurrency = AppCurrency.Default,
isCustomSelected = false,
notifications = persistentListOf(),
)
}
private fun createTestFeeSelectorUMV2(): FeeSelectorUMV2.Content {
val fee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Single(fee)
return FeeSelectorUMV2.Content(
fees = transactionFee,
feeItems = persistentListOf(
FeeItem.Market(fee),
),
selectedFeeItem = FeeItem.Market(fee),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
appCurrency = appCurrency,
),
feeNonce = FeeNonce.Nonce(
nonce = BigInteger.ZERO,
onNonceChange = {},
),
)
}
private fun createFeeTooLowUM(): FeeUM.Content {
val fee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.0001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = fee,
priority = fee,
)
return FeeUM.Content(
feeSelectorUM = FeeSelectorUM.Content(
fees = transactionFee,
selectedType = FeeType.Custom,
selectedFee = fee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.0001",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
nonce = BigInteger.ZERO,
),
rate = BigDecimal("50000"),
isFeeConvertibleToFiat = true,
isFeeApproximate = false,
isTronToken = false,
isEditingDisabled = false,
isPrimaryButtonEnabled = true,
appCurrency = AppCurrency.Default,
isCustomSelected = true,
notifications = persistentListOf(),
)
}
private fun createFeeTooLowUMV2(): FeeSelectorUMV2.Content {
val fee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.0001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = fee,
priority = fee,
)
return FeeSelectorUMV2.Content(
fees = transactionFee,
feeItems = persistentListOf(
FeeItem.Custom(
fee = fee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.0001",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
),
selectedFeeItem = FeeItem.Custom(
fee = fee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.0001",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
appCurrency = appCurrency,
),
feeNonce = FeeNonce.Nonce(
nonce = BigInteger.ZERO,
onNonceChange = {},
),
)
}
private fun createFeeTooHighUM(): FeeUM.Content {
val priorityFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val customFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.01"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = minimumFee,
priority = priorityFee,
)
return FeeUM.Content(
feeSelectorUM = FeeSelectorUM.Content(
fees = transactionFee,
selectedType = FeeType.Custom,
selectedFee = customFee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.01",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
nonce = BigInteger.ZERO,
),
rate = BigDecimal("50000"),
isFeeConvertibleToFiat = true,
isFeeApproximate = false,
isTronToken = false,
isEditingDisabled = false,
isPrimaryButtonEnabled = true,
appCurrency = AppCurrency.Default,
isCustomSelected = true,
notifications = persistentListOf(),
)
}
private fun createFeeTooHighUMV2(): FeeSelectorUMV2.Content {
val priorityFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val customFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.01"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = minimumFee,
priority = priorityFee,
)
return FeeSelectorUMV2.Content(
fees = transactionFee,
feeItems = persistentListOf(
FeeItem.Custom(
fee = customFee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.01",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
),
selectedFeeItem = FeeItem.Custom(
fee = customFee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.01",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
appCurrency = appCurrency,
),
feeNonce = FeeNonce.Nonce(
nonce = BigInteger.ZERO,
onNonceChange = {},
),
)
}
private fun createFeeTooHighAndTooLowUM(): FeeUM.Content {
val priorityFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.01"),
decimals = 8,
),
)
val customFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.008"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = minimumFee,
priority = priorityFee,
)
return FeeUM.Content(
feeSelectorUM = FeeSelectorUM.Content(
fees = transactionFee,
selectedType = FeeType.Custom,
selectedFee = customFee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.008",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
nonce = BigInteger.ZERO,
),
rate = BigDecimal("50000"),
isFeeConvertibleToFiat = true,
isFeeApproximate = false,
isTronToken = false,
isEditingDisabled = false,
isPrimaryButtonEnabled = true,
appCurrency = AppCurrency.Default,
isCustomSelected = true,
notifications = persistentListOf(),
)
}
private fun createFeeTooHighAndTooLowUMV2(): FeeSelectorUMV2.Content {
val priorityFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val minimumFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.01"),
decimals = 8,
),
)
val customFee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.008"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Choosable(
minimum = minimumFee,
normal = minimumFee,
priority = priorityFee,
)
return FeeSelectorUMV2.Content(
fees = transactionFee,
feeItems = persistentListOf(
FeeItem.Custom(
fee = customFee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.008",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
),
selectedFeeItem = FeeItem.Custom(
fee = customFee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.008",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
appCurrency = appCurrency,
),
feeNonce = FeeNonce.Nonce(
nonce = BigInteger.ZERO,
onNonceChange = {},
),
)
}
private fun createNormalFeeUM(): FeeUM.Content {
val fee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Single(fee)
return FeeUM.Content(
feeSelectorUM = FeeSelectorUM.Content(
fees = transactionFee,
selectedType = FeeType.Market,
selectedFee = fee,
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.001",
onValueChange = {},
keyboardOptions = mockk(relaxed = true),
keyboardActions = mockk(relaxed = true),
symbol = "TST",
decimals = 8,
title = mockk(relaxed = true),
footer = mockk(relaxed = true),
),
),
nonce = BigInteger.ZERO,
),
rate = BigDecimal("50000"),
isFeeConvertibleToFiat = true,
isFeeApproximate = false,
isTronToken = false,
isEditingDisabled = false,
isPrimaryButtonEnabled = true,
appCurrency = AppCurrency.Default,
isCustomSelected = false,
notifications = persistentListOf(),
)
}
private fun createNormalFeeSelectorUMV2(): FeeSelectorUMV2.Content {
val fee = Fee.Common(
amount = Amount(
currencySymbol = "TST",
value = BigDecimal("0.001"),
decimals = 8,
),
)
val transactionFee = TransactionFee.Single(fee)
return FeeSelectorUMV2.Content(
fees = transactionFee,
feeItems = persistentListOf(
FeeItem.Market(fee),
),
selectedFeeItem = FeeItem.Market(fee),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
appCurrency = appCurrency,
),
feeNonce = FeeNonce.Nonce(
nonce = BigInteger.ZERO,
onNonceChange = {},
),
)
}
companion object {
@JvmStatic
@BeforeAll
fun setUpLocale() {
Locale.setDefault(Locale.US)
}
}
}