Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-26 09:51:50 +03:00
commit 7e48e67354
942 changed files with 40912 additions and 7544 deletions

View file

@ -1,5 +1,12 @@
package com.tangem.features.send.v2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.send.v2.api.SendFeatureToggles
import javax.inject.Inject
internal class DefaultSendFeatureToggles : SendFeatureToggles
internal class DefaultSendFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : SendFeatureToggles {
override val isGaslessTransactionsEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("GASLESS_TRANSACTIONS_ENABLED")
}

View file

@ -19,7 +19,7 @@ import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.impl.R
@Composable
internal fun FeeBlock(feeSelectorUM: FeeSelectorUM) {
fun FeeBlockSuccess(feeSelectorUM: FeeSelectorUM) {
if (feeSelectorUM !is FeeSelectorUM.Content) return
val feeExtraInfo = feeSelectorUM.feeExtraInfo
val feeFiatRateUM = feeSelectorUM.feeFiatRateUM

View file

@ -8,25 +8,20 @@ import com.tangem.features.send.v2.sendnft.DefaultNFTSendComponent
import com.tangem.features.send.v2.subcomponents.notifications.DefaultSendNotificationsComponent
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 SendFeatureModule {
@Provides
fun provideSendFeatureToggles(): SendFeatureToggles {
return DefaultSendFeatureToggles()
}
}
internal object SendFeatureModule
@Module
@InstallIn(SingletonComponent::class)
internal interface SendFeatureModuleBinds {
@Binds
@Singleton
fun bindSendFeatureToggles(impl: DefaultSendFeatureToggles): SendFeatureToggles
@Binds
@Singleton
fun provideSendComponentFactory(impl: DefaultSendComponent.Factory): SendComponent.Factory

View file

@ -14,9 +14,10 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.extensions.conditional
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.FeeSelectorComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
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.feeselector.model.FeeSelectorBlockModel
import com.tangem.features.send.v2.feeselector.ui.FeeSelectorBlockContent
import com.tangem.utils.extensions.isSingleItem
import dagger.assisted.Assisted
@ -31,9 +32,10 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
@Assisted private val params: FeeSelectorParams.FeeSelectorBlockParams,
@Assisted onResult: (feeSelectorUM: FeeSelectorUM) -> Unit,
private val feeSelectorComponentFactory: FeeSelectorComponent.Factory,
private val sendFeatureToggles: SendFeatureToggles,
) : FeeSelectorBlockComponent, AppComponentContext by appComponentContext {
private val model: FeeSelectorModel = getOrCreateModel(params = params)
private val model: FeeSelectorBlockModel = getOrCreateModel(params = params)
private val bottomSheetSlot = childSlot(
source = model.feeSelectorBottomSheet,
@ -45,6 +47,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
params = FeeSelectorParams.FeeSelectorDetailsParams(
state = model.uiState.value,
onLoadFee = params.onLoadFee,
onLoadFeeExtended = params.onLoadFeeExtended,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
callback = model,
@ -52,6 +55,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
analyticsCategoryName = params.analyticsCategoryName,
analyticsSendSource = params.analyticsSendSource,
userWalletId = params.userWalletId,
),
onDismiss = {
model.feeSelectorBottomSheet.dismiss()
@ -61,6 +65,10 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
)
init {
bottomSheetSlot.subscribe {
params.bottomSheetShown(it.child != null)
}
model.uiState
.onEach { onResult(it) }
.launchIn(componentScope)
@ -77,11 +85,14 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
val isScreenSource = params.feeDisplaySource == FeeSelectorParams.FeeDisplaySource.Screen
val isNotSingleFee = (state as? FeeSelectorUM.Content)?.feeItems?.isSingleItem() == false
val isGaslessAvailable = state is FeeSelectorUM.Content &&
(state as FeeSelectorUM.Content).feeExtraInfo.transactionFeeExtended != null
FeeSelectorBlockContent(
state = state,
onReadMoreClick = model::onReadMoreClicked,
isGaslessFeatureEnabled = sendFeatureToggles.isGaslessTransactionsEnabled,
modifier = modifier
.conditional(isScreenSource && isNotSingleFee) {
.conditional(isScreenSource && (isNotSingleFee || isGaslessAvailable)) {
Modifier.clickable {
model.showFeeSelector()
}

View file

@ -3,11 +3,23 @@ package com.tangem.features.send.v2.feeselector
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.api.FeeSelectorComponent
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.v2.feeselector.component.extended.FeeExtendedSelectorComponent
import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorComponent
import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorComponent
import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel
import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute
import com.tangem.features.send.v2.feeselector.ui.FeeSelectorModalBottomSheet
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -17,10 +29,33 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: FeeSelectorParams.FeeSelectorDetailsParams,
@Assisted private val onDismiss: () -> Unit,
private val feeExtendedSelectorComponentFactory: FeeExtendedSelectorComponent.Factory,
private val feeSpeedSelectorComponentFactory: FeeSpeedSelectorComponent.Factory,
private val feeTokenSelectorComponentFactory: FeeTokenSelectorComponent.Factory,
) : FeeSelectorComponent, AppComponentContext by appComponentContext {
private val model: FeeSelectorModel = getOrCreateModel(params = params)
val innerRouter = InnerRouter<FeeSelectorRoute>(
stackNavigation = model.stackNavigation,
popCallback = {
model.stackNavigation.pop { success ->
if (!success) {
dismiss()
}
}
},
)
private val contentStack = childStack(
key = "FeeSelectorDetailsStack",
source = model.stackNavigation,
serializer = FeeSelectorRoute.serializer(),
initialConfiguration = model.getInitialRoute(),
handleBackButton = false,
childFactory = ::screenChild,
)
override fun dismiss() {
onDismiss()
}
@ -28,14 +63,46 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor(
@Composable
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
val contentStack by contentStack.subscribeAsState()
FeeSelectorModalBottomSheet(
onDismiss = ::dismiss,
childStack = contentStack,
state = state,
feeSelectorIntents = model,
feeDisplaySource = params.feeDisplaySource,
onDismiss = onDismiss,
onBack = innerRouter::pop,
)
}
private fun screenChild(config: FeeSelectorRoute, componentContext: ComponentContext): ComposableContentComponent {
val appComponentContext = childByContext(
componentContext = componentContext,
router = innerRouter,
)
val componentParams = FeeSelectorComponentParams(
parentParams = params,
state = model.uiState,
intents = model,
)
return when (config) {
FeeSelectorRoute.NetworkFee -> feeExtendedSelectorComponentFactory.create(
appComponentContext = appComponentContext,
params = componentParams,
)
FeeSelectorRoute.ChooseSpeed -> feeSpeedSelectorComponentFactory.create(
appComponentContext = appComponentContext,
params = componentParams,
)
FeeSelectorRoute.ChooseToken -> feeTokenSelectorComponentFactory.create(
appComponentContext = appComponentContext,
params = componentParams,
)
}
}
@AssistedFactory
interface Factory : FeeSelectorComponent.Factory {
override fun create(

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.v2.feeselector.component
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.FeeSelectorIntents
import kotlinx.coroutines.flow.MutableStateFlow
internal class FeeSelectorComponentParams(
val parentParams: FeeSelectorParams.FeeSelectorDetailsParams,
val state: MutableStateFlow<FeeSelectorUM>,
val intents: FeeSelectorIntents,
)

View file

@ -0,0 +1,41 @@
package com.tangem.features.send.v2.feeselector.component.extended
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.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.v2.feeselector.component.extended.model.FeeExtendedSelectorModel
import com.tangem.features.send.v2.feeselector.component.extended.ui.FeeExtendedSelectorContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class FeeExtendedSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: FeeSelectorComponentParams,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: FeeExtendedSelectorModel = getOrCreateModel(params = params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
FeeExtendedSelectorContent(
modifier = modifier,
state = state,
)
}
@AssistedFactory
interface Factory {
fun create(
appComponentContext: AppComponentContext,
params: FeeSelectorComponentParams,
): FeeExtendedSelectorComponent
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.send.v2.feeselector.component.extended.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
@Immutable
data class FeeExtendedSelectorUM(
val parent: FeeSelectorUM.Content,
val token: TokenItemState,
val fee: FeeItem,
val onFeeClick: () -> Unit,
)

View file

@ -0,0 +1,72 @@
package com.tangem.features.send.v2.feeselector.component.extended.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
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.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.v2.feeselector.component.extended.entity.FeeExtendedSelectorUM
import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
class FeeExtendedSelectorModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val router: Router,
) : Model() {
private val params = paramsContainer.require<FeeSelectorComponentParams>()
private var appCurrency: AppCurrency = AppCurrency.Default
init {
initAppCurrency()
}
val uiState: StateFlow<FeeExtendedSelectorUM>
field = MutableStateFlow<FeeExtendedSelectorUM>(getInitialState())
init {
params.state
.filterIsInstance<FeeSelectorUM.Content>()
.onEach { state -> uiState.value = convertState(state) }
.launchIn(modelScope)
}
private fun getInitialState(): FeeExtendedSelectorUM {
val parentContentState = params.state.value as FeeSelectorUM.Content
return convertState(parentContentState)
}
private fun initAppCurrency() {
modelScope.launch {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
}
}
private fun convertState(state: FeeSelectorUM.Content): FeeExtendedSelectorUM {
val tokenConverter = SelectedTokenItemConverter(
appCurrencyProvider = Provider { appCurrency },
onTokenClick = { router.push(FeeSelectorRoute.ChooseToken) },
isTokenSelectionAvailable = state.feeExtraInfo.availableFeeCurrencies.orEmpty().size > 1,
)
return FeeExtendedSelectorUM(
parent = state,
token = tokenConverter.convert(state.feeExtraInfo.feeCryptoCurrencyStatus),
fee = state.selectedFeeItem,
onFeeClick = { router.push(FeeSelectorRoute.ChooseSpeed) },
)
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.send.v2.feeselector.component.extended.model
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.ui.components.icons.IconTint
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
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.CryptoCurrencyStatus
import com.tangem.features.send.v2.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SelectedTokenItemConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
private val onTokenClick: () -> Unit,
private val isTokenSelectionAvailable: Boolean,
) : Converter<CryptoCurrencyStatus, TokenItemState> {
override fun convert(value: CryptoCurrencyStatus): TokenItemState {
if (value.value !is CryptoCurrencyStatus.Loaded) {
return TokenItemState.Loading(id = value.currency.id.value)
}
val appCurrency = appCurrencyProvider()
val tokenItemConverter = TokenItemStateConverter(
appCurrency = appCurrencyProvider(),
onItemClick = if (isTokenSelectionAvailable) {
{ _, _ -> onTokenClick() }
} else {
null
},
)
val state = tokenItemConverter.convert(value)
return when (state) {
is TokenItemState.Content -> {
val balance = value.value.fiatAmount.format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
state.copy(
fiatAmountState = if (isTokenSelectionAvailable) {
TokenItemState.FiatAmountState.Icon(
iconRes = R.drawable.ic_select_18_24,
tint = IconTint.Informative,
)
} else {
TokenItemState.FiatAmountState.Empty
},
subtitleState = TokenItemState.SubtitleState.TextContent(
value = resourceReference(
R.string.common_balance,
wrappedList(balance),
),
isAvailable = false,
),
subtitle2State = null,
)
}
else -> state
}
}
}

View file

@ -0,0 +1,194 @@
package com.tangem.features.send.v2.feeselector.component.extended.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
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.getFiatReference
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.IconTint
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.stringReference
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.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.feeselector.component.extended.entity.FeeExtendedSelectorUM
import com.tangem.features.send.v2.feeselector.component.speed.ui.RegularFeeItemContent
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Composable
fun FeeExtendedSelectorContent(state: FeeExtendedSelectorUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
)
.fillMaxWidth(),
) {
TokenItem(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action),
state = state.token,
isBalanceHidden = false,
)
SpacerH8()
val feeFiatRateUM = state.parent.feeFiatRateUM
val fee = state.fee.fee
val isChooseSpeedAvailable = state.parent.feeItems.size > 1
RegularFeeItemContent(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.conditional(isChooseSpeedAvailable) {
clickable(onClick = state.onFeeClick)
}
.background(
TangemTheme.colors.background.action,
RoundedCornerShape(TangemTheme.dimens.radius14),
),
title = state.fee.title,
iconRes = state.fee.iconRes,
iconBackgroundColor = TangemTheme.colors.icon.accent.copy(alpha = 0.1F),
iconTint = TangemTheme.colors.icon.accent,
preDot = stringReference(
fee.amount.value.format {
crypto(
symbol = fee.amount.currencySymbol,
decimals = fee.amount.decimals,
).fee(canBeLower = state.parent.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (state.parent.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) {
getFiatReference(
value = fee.amount.value,
rate = feeFiatRateUM.rate,
appCurrency = feeFiatRateUM.appCurrency,
)
} else {
null
},
ellipsizeOffset = fee.amount.currencySymbol.length,
showSelectorIcon = isChooseSpeedAvailable,
isNotEnoughFunds = state.parent.feeExtraInfo.isNotEnoughFunds,
isLoading = state.fee.isLoading(),
)
}
}
@Suppress("LongMethod")
@Preview
@Composable
private fun Preview() {
val tokenItemState = TokenItemState.Content(
id = "1",
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")),
fiatAmountState = TokenItemState.FiatAmountState.Icon(
R.drawable.ic_select_18_24,
tint = IconTint.Informative,
),
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference("Balance: 0,35853044 BTC"),
isAvailable = false,
),
onItemClick = {},
onItemLongClick = {},
)
val cryptoCurrencyStatus = CryptoCurrencyStatus(
currency = CryptoCurrency.Coin(
id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"),
network = Network(
id = Network.ID(
value = "bitcoin",
derivationPath = Network.DerivationPath.None,
),
backendId = "bitcoin",
name = "Bitcoin",
currencySymbol = "BTC",
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("bitcoin"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
),
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png",
isCustom = false,
),
value = CryptoCurrencyStatus.Loading,
)
val lowFeeItem =
FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum)))
TangemThemePreview {
FeeExtendedSelectorContent(
modifier = Modifier.background(TangemTheme.colors.background.tertiary),
state = FeeExtendedSelectorUM(
parent = FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
feeItems = persistentListOf(lowFeeItem),
selectedFeeItem = lowFeeItem,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
isNotEnoughFunds = true,
),
feeNonce = FeeNonce.None,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500"),
appCurrency = AppCurrency.Default,
),
fees = TransactionFee.Single(lowFeeItem.fee),
),
token = tokenItemState,
fee = FeeItem.Market(
fee = Fee.Common(
Amount(
value = BigDecimal("0.02"),
blockchain = Blockchain.Ethereum,
),
),
),
onFeeClick = {},
),
)
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.v2.feeselector.component.speed
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.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.v2.feeselector.component.speed.model.FeeSpeedSelectorModel
import com.tangem.features.send.v2.feeselector.component.speed.ui.FeeSpeedSelectorContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class FeeSpeedSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: FeeSelectorComponentParams,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val model: FeeSpeedSelectorModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
if (state is FeeSelectorUM.Content) {
FeeSpeedSelectorContent(
state = state as FeeSelectorUM.Content,
intents = model,
)
}
}
@AssistedFactory
interface Factory {
fun create(
appComponentContext: AppComponentContext,
params: FeeSelectorComponentParams,
): FeeSpeedSelectorComponent
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.send.v2.feeselector.component.speed
import androidx.compose.runtime.Stable
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents
@Stable
internal interface FeeSpeedSelectorIntents : FeeSelectorIntents {
fun onLearnMoreClick()
}
@Stable
internal class StubFeeSpeedSelectorIntents : FeeSpeedSelectorIntents, FeeSelectorIntents by StubFeeSelectorIntents() {
override fun onLearnMoreClick() {}
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.send.v2.feeselector.component.speed.model
import androidx.compose.runtime.Stable
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorIntents
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class FeeSpeedSelectorModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
) : Model(), FeeSpeedSelectorIntents,
FeeSelectorIntents by paramsContainer.require<FeeSelectorComponentParams>().intents {
private val params = paramsContainer.require<FeeSelectorComponentParams>()
val uiState: StateFlow<FeeSelectorUM>
field = params.state
override fun onLearnMoreClick() {
modelScope.launch {
urlOpener.openUrl(
TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee),
)
}
}
}

View file

@ -0,0 +1,578 @@
package com.tangem.features.send.v2.feeselector.component.speed.ui
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
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.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
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.SpacerH8
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.components.atoms.text.TextEllipsis
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
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.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.feeselector.component.speed.FeeSpeedSelectorIntents
import com.tangem.features.send.v2.feeselector.component.speed.StubFeeSpeedSelectorIntents
import com.tangem.features.send.v2.impl.R
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Composable
internal fun FeeSpeedSelectorContent(
state: FeeSelectorUM.Content,
intents: FeeSpeedSelectorIntents,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Title(onLearnMoreClick = intents::onLearnMoreClick)
SpacerH8()
FeeSelectorItems(
state = state,
intents = intents,
)
}
}
@Composable
private fun Title(modifier: Modifier = Modifier, onLearnMoreClick: () -> Unit) {
val linkText = stringResourceSafe(R.string.common_learn_more)
val fullString = stringResourceSafe(R.string.fee_selector_choose_speed_description, linkText)
val linkTextPosition = fullString.length - linkText.length
val defaultColor = TangemTheme.colors.text.secondary
val linkColor = TangemTheme.colors.text.accent
val annotatedString = remember(defaultColor, linkColor, onLearnMoreClick) {
buildAnnotatedString {
withStyle(SpanStyle(defaultColor)) {
append(fullString.substring(0, linkTextPosition))
}
withLink(
link = LinkAnnotation.Clickable(
tag = "learn_more",
linkInteractionListener = { onLearnMoreClick() },
),
block = {
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.None)) {
append(
fullString.substring(linkTextPosition, fullString.length).replace(
' ',
StringsSigns.NON_BREAKING_SPACE,
),
)
}
},
)
}
}
Text(
modifier = modifier.padding(horizontal = 32.dp),
text = annotatedString,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
}
@Composable
private fun FeeSelectorItems(
state: FeeSelectorUM.Content,
intents: FeeSpeedSelectorIntents,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
val feeFiatRateUM = state.feeFiatRateUM
state.feeItems.fastForEachIndexed { index, item ->
val isSelected = item.isSameClass(state.selectedFeeItem)
val iconTint by animateColorAsState(
targetValue = if (isSelected) TangemTheme.colors.icon.accent else TangemTheme.colors.text.tertiary,
label = "Fee selector icon tint change",
)
val iconBackgroundColor by animateColorAsState(
targetValue = if (isSelected) {
TangemTheme.colors.icon.accent.copy(alpha = 0.1F)
} else {
TangemTheme.colors.background.secondary
},
label = "Fee selector icon background change",
)
val itemModifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth()
.selectedBorder(isSelected = isSelected)
.clickableSingle(onClick = { intents.onFeeItemSelected(item) })
.background(TangemTheme.colors.background.action)
when (item) {
is FeeItem.Custom -> CustomFeeBlock(
modifier = itemModifier,
customFee = item,
isSelected = isSelected,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
onValueChange = intents::onCustomFeeValueChange,
nonce = state.feeNonce,
)
else -> RegularFeeItemContent(
modifier = itemModifier,
title = item.title,
iconRes = item.iconRes,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
preDot = stringReference(
item.fee.amount.value.format {
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (state.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) {
getFiatReference(
value = item.fee.amount.value,
rate = feeFiatRateUM.rate,
appCurrency = feeFiatRateUM.appCurrency,
)
} else {
null
},
ellipsizeOffset = item.fee.amount.currencySymbol.length,
)
}
}
}
}
@Suppress("LongParameterList")
@Composable
private fun CustomFeeBlock(
customFee: FeeItem.Custom,
isSelected: Boolean,
iconBackgroundColor: Color,
iconTint: Color,
onValueChange: (Int, String) -> Unit,
nonce: FeeNonce,
modifier: Modifier = Modifier,
) {
val textMeasurer = rememberTextMeasurer()
val textHeight = with(LocalDensity.current) {
textMeasurer.measure("S", style = TangemTheme.typography.subtitle2).size.height.toDp() +
textMeasurer.measure("S", style = TangemTheme.typography.caption1).size.height.toDp() + 2.dp
}
Column(modifier = modifier) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 15.dp)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_FEE_ITEM),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.size(36.dp)
.background(color = iconBackgroundColor, shape = CircleShape)
.padding(6.dp)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_ICON),
painter = painterResource(R.drawable.ic_edit_v2_24),
tint = iconTint,
contentDescription = null,
)
Box(Modifier.heightIn(min = textHeight)) {
Text(
text = stringResourceSafe(R.string.common_custom),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
modifier = Modifier
.align(Alignment.CenterStart)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_TITLE),
)
}
}
AnimatedVisibility(
visible = isSelected,
label = "Custom Fee Selected Animation",
enter = expandVertically().plus(fadeIn()),
exit = shrinkVertically().plus(fadeOut()),
) {
ExpandedCustomFeeItems(
customFeeFields = customFee.customValues,
onValueChange = onValueChange,
nonce = nonce,
)
}
}
}
@Composable
private fun ExpandedCustomFeeItems(
customFeeFields: ImmutableList<CustomFeeFieldUM>,
onValueChange: (Int, String) -> Unit,
nonce: FeeNonce,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
customFeeFields.fastForEachIndexed { index, field ->
val isShowDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce
if (field.label != null) {
InputRowEnterInfoAmountV2(
text = field.value,
decimals = field.decimals,
symbol = field.symbol,
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
info = field.label,
description = field.footer,
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
onValueChange = { onValueChange(index, it) },
showDivider = isShowDivider,
isReadOnly = field.isReadonly,
)
} else {
InputRowEnterInfoAmountV2(
text = field.value,
decimals = field.decimals,
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
symbol = field.symbol,
description = field.footer,
onValueChange = { onValueChange(index, it) },
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
showDivider = isShowDivider,
)
}
}
if (nonce is FeeNonce.Nonce) {
InputRowEnter(
text = nonce.nonce?.toString().orEmpty(),
title = resourceReference(R.string.send_nonce),
description = resourceReference(R.string.send_nonce_footer),
onValueChange = nonce.onNonceChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
placeholder = resourceReference(R.string.send_nonce_hint),
titleColor = TangemTheme.colors.text.secondary,
showDivider = false,
modifier = Modifier
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_ITEM),
)
}
}
}
@Composable
internal fun RegularFeeItemContent(
title: TextReference,
@DrawableRes iconRes: Int,
iconBackgroundColor: Color,
iconTint: Color,
modifier: Modifier = Modifier,
preDot: TextReference? = null,
postDot: TextReference? = null,
ellipsizeOffset: Int? = null,
showSelectorIcon: Boolean = false,
isLoading: Boolean = false,
isNotEnoughFunds: Boolean = false,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 15.dp)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.size(36.dp)
.background(color = iconBackgroundColor, shape = CircleShape)
.padding(6.dp)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_ICON),
painter = painterResource(iconRes),
tint = iconTint,
contentDescription = null,
)
FeeDescription(
title = title,
preDot = preDot,
postDot = postDot,
ellipsizeOffset = ellipsizeOffset,
isLoading = isLoading,
isNotEnoughFunds = isNotEnoughFunds,
)
if (showSelectorIcon) {
SpacerWMax()
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_select_18_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}
}
}
@Composable
private fun FeeDescription(
title: TextReference,
modifier: Modifier = Modifier,
preDot: TextReference? = null,
postDot: TextReference? = null,
ellipsizeOffset: Int? = null,
isLoading: Boolean = false,
isNotEnoughFunds: Boolean = false,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = title.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE),
)
when {
isNotEnoughFunds -> Text(
text = stringResourceSafe(R.string.gasless_not_enough_funds_to_cover_token_fee),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption1,
)
isLoading -> TextShimmer(
modifier = Modifier.width(122.dp),
style = TangemTheme.typography.caption1,
)
preDot != null -> FeeValueContent(preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset)
}
}
}
@Composable
private fun FeeValueContent(preDot: TextReference, postDot: TextReference?, ellipsizeOffset: Int? = null) {
val ellipsis = if (ellipsizeOffset == null) {
TextEllipsis.End
} else {
TextEllipsis.OffsetEnd(ellipsizeOffset)
}
val textColor = TangemTheme.colors.text.tertiary
val textStyle = TangemTheme.typography.caption1
Row {
EllipsisText(
text = preDot.resolveReference(),
style = textStyle,
color = textColor,
textAlign = TextAlign.End,
ellipsis = ellipsis,
modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.TOKEN_AMOUNT),
)
if (postDot != null) {
Text(
text = StringsSigns.DOT,
style = textStyle,
color = textColor,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing4)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.DOT_SIGN),
)
Text(
text = postDot.resolveReference(),
style = textStyle,
color = textColor,
modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.FIAT_AMOUNT),
)
}
}
}
@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 Preview(
@PreviewParameter(FeeSpeedSelectorUMContentProvider::class)
state: FeeSelectorUM.Content,
) {
TangemThemePreview {
Box(Modifier.background(TangemTheme.colors.background.secondary)) {
FeeSpeedSelectorContent(
state = state,
intents = StubFeeSpeedSelectorIntents(),
)
}
}
}
private class FeeSpeedSelectorUMContentProvider : CollectionPreviewParameterProvider<FeeSelectorUM.Content>(
collection = listOf(
FeeSelectorUM.Content(
isPrimaryButtonEnabled = false,
feeItems = persistentListOf(
FeeItem.Suggested(
title = resourceReference(
id = R.string.wc_fee_suggested,
formatArgs = wrappedList("Tangem"),
),
fee = Fee.Common(Amount(value = BigDecimal("0.1"), blockchain = Blockchain.Ethereum)),
),
FeeItem.Slow(fee = Fee.Common(Amount(value = BigDecimal("0.01"), blockchain = Blockchain.Ethereum))),
FeeItem.Market(
fee = Fee.Common(
Amount(
value = BigDecimal("0.02"),
blockchain = Blockchain.Ethereum,
),
),
),
FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))),
customFeeItem,
),
selectedFeeItem = FeeItem.Slow(
fee = Fee.Common(
Amount(
value = BigDecimal("0.01"),
blockchain = Blockchain.Ethereum,
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = true,
isFeeConvertibleToFiat = true,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal.TEN,
appCurrency = AppCurrency.Default,
),
feeNonce = FeeNonce.None,
fees = TransactionFee.Single(customFeeItem.fee),
),
),
)
private val cryptoCurrencyStatus
get() = CryptoCurrencyStatus(
currency = CryptoCurrency.Coin(
id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"),
network = Network(
id = Network.ID(
value = "bitcoin",
derivationPath = Network.DerivationPath.None,
),
backendId = "bitcoin",
name = "Bitcoin",
currencySymbol = "BTC",
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("bitcoin"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
),
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png",
isCustom = false,
),
value = CryptoCurrencyStatus.Loading,
)
private val customFeeItem
get() = FeeItem.Custom(
fee = Fee.Common(Amount(value = BigDecimal("0.05"), blockchain = Blockchain.Ethereum)),
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.119806",
onValueChange = {},
keyboardOptions = KeyboardOptions(),
keyboardActions = KeyboardActions(),
symbol = "ETH",
decimals = 8,
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_custom_amount_fee_footer),
label = stringReference("~ 0,03 \$"),
isReadonly = false,
),
CustomFeeFieldUM(
value = "40000",
onValueChange = {},
keyboardOptions = KeyboardOptions(),
keyboardActions = KeyboardActions(),
symbol = "GWEI",
decimals = 8,
title = resourceReference(R.string.send_gas_price),
footer = resourceReference(R.string.send_gas_price_footer),
label = null,
isReadonly = false,
),
CustomFeeFieldUM(
value = "31400",
onValueChange = {},
keyboardOptions = KeyboardOptions(),
keyboardActions = KeyboardActions(),
symbol = null,
decimals = 8,
title = resourceReference(R.string.send_gas_limit),
footer = resourceReference(R.string.send_gas_limit_footer),
label = null,
isReadonly = false,
),
),
)

View file

@ -0,0 +1,42 @@
package com.tangem.features.send.v2.feeselector.component.token
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.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.v2.feeselector.component.token.model.FeeTokenSelectorModel
import com.tangem.features.send.v2.feeselector.component.token.ui.FeeTokenSelectorContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class FeeTokenSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: FeeSelectorComponentParams,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val model: FeeTokenSelectorModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
FeeTokenSelectorContent(
state = state,
intents = model,
modifier = modifier,
)
}
@AssistedFactory
interface Factory {
fun create(
appComponentContext: AppComponentContext,
params: FeeSelectorComponentParams,
): FeeTokenSelectorComponent
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.send.v2.feeselector.component.token
import androidx.compose.runtime.Stable
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents
@Stable
internal interface FeeTokenSelectorIntents : FeeSelectorIntents {
fun onLearnMoreClick()
}
@Stable
internal class StubFeeTokenSelectorIntents : FeeTokenSelectorIntents, FeeSelectorIntents by StubFeeSelectorIntents() {
override fun onLearnMoreClick() {}
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.send.v2.feeselector.component.token.entity
import com.tangem.core.ui.components.token.state.TokenItemState
internal data class FeeTokenItemState(
val state: TokenItemState,
val isAvailable: Boolean = true,
)

View file

@ -0,0 +1,10 @@
package com.tangem.features.send.v2.feeselector.component.token.entity
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import kotlinx.collections.immutable.ImmutableList
internal data class FeeTokenSelectorUM(
val parent: FeeSelectorUM.Content,
val selectedToken: FeeTokenItemState,
val tokens: ImmutableList<FeeTokenItemState>,
)

View file

@ -0,0 +1,66 @@
package com.tangem.features.send.v2.feeselector.component.token.model
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
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.CryptoCurrencyStatus
import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenItemState
import com.tangem.features.send.v2.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
internal class FeeTokenForListConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
private val onTokenClick: () -> Unit,
) : Converter<CryptoCurrencyStatus, FeeTokenItemState> {
override fun convert(value: CryptoCurrencyStatus): FeeTokenItemState {
if (value.value !is CryptoCurrencyStatus.Loaded) {
return FeeTokenItemState(TokenItemState.Loading(id = value.currency.id.value))
}
val appCurrency = appCurrencyProvider()
val isBalanceZero = value.value.amount?.isZero() == true
val tokenItemConverter = TokenItemStateConverter(
appCurrency = appCurrencyProvider(),
onItemClick = if (isBalanceZero.not()) {
{ _, _ -> onTokenClick() }
} else {
null
},
)
val state = tokenItemConverter.convert(value)
val updatedState = when (state) {
is TokenItemState.Content -> {
val balance = value.value.fiatAmount.format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
state.copy(
fiatAmountState = TokenItemState.FiatAmountState.Empty,
subtitleState = TokenItemState.SubtitleState.TextContent(
value = resourceReference(
R.string.common_balance,
wrappedList(balance),
),
isAvailable = false,
),
subtitle2State = null,
)
}
else -> state
}
return FeeTokenItemState(
state = updatedState,
isAvailable = isBalanceZero.not(),
)
}
}

View file

@ -0,0 +1,95 @@
package com.tangem.features.send.v2.feeselector.component.token.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.feeselector.component.FeeSelectorComponentParams
import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorIntents
import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenItemState
import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenSelectorUM
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class FeeTokenSelectorModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
) : Model(), FeeTokenSelectorIntents,
FeeSelectorIntents by paramsContainer.require<FeeSelectorComponentParams>().intents {
private val params = paramsContainer.require<FeeSelectorComponentParams>()
private var appCurrency = AppCurrency.Default
val uiState: StateFlow<FeeTokenSelectorUM>
field = MutableStateFlow(getInitialState())
init {
initAppCurrency()
params.state
.filterIsInstance<FeeSelectorUM.Content>()
.onEach { state -> uiState.value = stateFromParent(state) }
.launchIn(modelScope)
}
private fun initAppCurrency() {
modelScope.launch {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
}
}
private fun getInitialState(): FeeTokenSelectorUM {
val parentState = params.state.value as FeeSelectorUM.Content
return stateFromParent(parentState)
}
private fun stateFromParent(newParentState: FeeSelectorUM.Content): FeeTokenSelectorUM {
val parentState = newParentState
val selectedCurrencyStatus = parentState.feeExtraInfo.feeCryptoCurrencyStatus
val selectedTokenUM = convertToken(selectedCurrencyStatus)
return FeeTokenSelectorUM(
parent = parentState,
selectedToken = selectedTokenUM,
tokens = parentState.feeExtraInfo.availableFeeCurrencies?.map { status ->
convertToken(status)
}?.toImmutableList() ?: persistentListOf(selectedTokenUM),
)
}
private fun convertToken(status: CryptoCurrencyStatus): FeeTokenItemState {
return FeeTokenForListConverter(
appCurrencyProvider = Provider { appCurrency },
onTokenClick = { onTokenSelected(status) },
).convert(status)
}
override fun onLearnMoreClick() {
modelScope.launch {
urlOpener.openUrl(
TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee),
)
}
}
}

View file

@ -0,0 +1,270 @@
package com.tangem.features.send.v2.feeselector.component.token.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
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.core.ui.components.SpacerH8
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.selectedBorder
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.feeselector.component.token.FeeTokenSelectorIntents
import com.tangem.features.send.v2.feeselector.component.token.StubFeeTokenSelectorIntents
import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenItemState
import com.tangem.features.send.v2.feeselector.component.token.entity.FeeTokenSelectorUM
import com.tangem.features.send.v2.impl.R
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@Composable
internal fun FeeTokenSelectorContent(
state: FeeTokenSelectorUM,
intents: FeeTokenSelectorIntents,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Title(onLearnMoreClick = intents::onLearnMoreClick)
SpacerH8()
TokenListContent(state)
}
}
@Composable
private fun Title(modifier: Modifier = Modifier, onLearnMoreClick: () -> Unit) {
val linkText = stringResourceSafe(R.string.common_learn_more)
val fullString = stringResourceSafe(R.string.fee_selector_choose_token_description, linkText)
val linkTextPosition = fullString.length - linkText.length
val defaultColor = TangemTheme.colors.text.secondary
val linkColor = TangemTheme.colors.text.accent
val annotatedString = remember(defaultColor, linkColor, onLearnMoreClick) {
buildAnnotatedString {
withStyle(SpanStyle(defaultColor)) {
append(fullString.substring(0, linkTextPosition))
}
withLink(
link = LinkAnnotation.Clickable(
tag = "learn_more",
linkInteractionListener = { onLearnMoreClick() },
),
block = {
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.None)) {
append(
fullString.substring(linkTextPosition, fullString.length).replace(
' ',
StringsSigns.NON_BREAKING_SPACE,
),
)
}
},
)
}
}
Text(
modifier = modifier.padding(horizontal = 32.dp),
text = annotatedString,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
}
@Suppress("MagicNumber")
@Composable
internal fun TokenListContent(state: FeeTokenSelectorUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val (availableTokens, notAvailableTokens) = remember(state.tokens) {
state.tokens.partition { it.isAvailable }.let { pair ->
Pair(
first = pair.first.map { it.state }.toImmutableList(),
second = pair.second.map { it.state }.toImmutableList(),
)
}
}
availableTokens.fastForEach { item ->
key(item.id) {
TokenItem(
modifier = Modifier
.selectedBorder(isSelected = state.selectedToken.state.id == item.id)
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action),
state = item,
isBalanceHidden = false,
)
}
}
if (notAvailableTokens.isNotEmpty()) {
Text(
modifier = Modifier.padding(start = 12.dp, top = 8.dp),
text = stringResourceSafe(R.string.common_not_available),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
notAvailableTokens.fastForEach { item ->
key(item.id) {
TokenItem(
modifier = Modifier
.graphicsLayer { alpha = 0.5f }
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action),
state = item,
isBalanceHidden = false,
)
}
}
}
}
}
@Suppress("LongMethod")
@Preview
@Composable
private fun Preview() {
val cryptoCurrencyStatus = CryptoCurrencyStatus(
currency = CryptoCurrency.Coin(
id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"),
network = Network(
id = Network.ID(
value = "bitcoin",
derivationPath = Network.DerivationPath.None,
),
backendId = "bitcoin",
name = "Bitcoin",
currencySymbol = "BTC",
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("bitcoin"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
),
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png",
isCustom = false,
),
value = CryptoCurrencyStatus.Loading,
)
val parentUM = FeeSelectorUM.Content(
isPrimaryButtonEnabled = false,
feeItems = persistentListOf(
FeeItem.Market(
fee = Fee.Common(
Amount(
value = BigDecimal("0.02"),
blockchain = Blockchain.Ethereum,
),
),
),
),
selectedFeeItem = FeeItem.Slow(
fee = Fee.Common(
Amount(
value = BigDecimal("0.01"),
blockchain = Blockchain.Ethereum,
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = true,
isFeeConvertibleToFiat = true,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal.TEN,
appCurrency = AppCurrency.Default,
),
feeNonce = FeeNonce.None,
fees = TransactionFee.Single(
Fee.Common(
Amount(
value = BigDecimal("0.01"),
blockchain = Blockchain.Ethereum,
),
),
),
)
val tokenItemState = TokenItemState.Content(
id = "1",
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")),
fiatAmountState = TokenItemState.FiatAmountState.Empty,
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference("Balance: 0,35853044 BTC"),
isAvailable = false,
),
onItemClick = {},
onItemLongClick = {},
)
val feeTokenItemState = FeeTokenItemState(tokenItemState)
val state = FeeTokenSelectorUM(
parent = parentUM,
selectedToken = feeTokenItemState,
tokens = persistentListOf(
feeTokenItemState,
feeTokenItemState.copy(state = tokenItemState.copy(id = "2")),
feeTokenItemState.copy(state = tokenItemState.copy(id = "3"), isAvailable = false),
feeTokenItemState.copy(state = tokenItemState.copy(id = "4"), isAvailable = false),
),
)
TangemThemePreview {
FeeTokenSelectorContent(
state = state,
intents = StubFeeTokenSelectorIntents(),
modifier = Modifier
.background(TangemTheme.colors.background.tertiary),
)
}
}

View file

@ -2,6 +2,10 @@ package com.tangem.features.send.v2.feeselector.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.send.v2.feeselector.component.extended.model.FeeExtendedSelectorModel
import com.tangem.features.send.v2.feeselector.model.FeeSelectorBlockModel
import com.tangem.features.send.v2.feeselector.component.speed.model.FeeSpeedSelectorModel
import com.tangem.features.send.v2.feeselector.component.token.model.FeeTokenSelectorModel
import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel
import dagger.Binds
import dagger.Module
@ -16,4 +20,24 @@ internal interface FeeSelectorModelModule {
@IntoMap
@ClassKey(FeeSelectorModel::class)
fun bindModel(model: FeeSelectorModel): Model
@Binds
@IntoMap
@ClassKey(FeeExtendedSelectorModel::class)
fun bindExtendedModel(model: FeeExtendedSelectorModel): Model
@Binds
@IntoMap
@ClassKey(FeeSelectorBlockModel::class)
fun bindBlockModel(model: FeeSelectorBlockModel): Model
@Binds
@IntoMap
@ClassKey(FeeSpeedSelectorModel::class)
fun bindFeeSpeedSelectorModel(model: FeeSpeedSelectorModel): Model
@Binds
@IntoMap
@ClassKey(FeeTokenSelectorModel::class)
fun bindFeeTokenSelectorModel(model: FeeTokenSelectorModel): Model
}

View file

@ -92,6 +92,7 @@ internal class FeeSelectorAlertFactory @Inject constructor(
is FeeItem.Slow -> newTransactionFee.minimum
is FeeItem.Market -> newTransactionFee.normal
is FeeItem.Fast -> newTransactionFee.priority
FeeItem.Loading -> newTransactionFee.normal
is FeeItem.Custom -> return
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.features.send.v2.feeselector.model
import androidx.compose.runtime.Stable
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.TangemBlogUrlBuilder
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.url.UrlOpener
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class FeeSelectorBlockModel @Inject constructor(
paramsContainer: ParamsContainer,
feeSelectorLogicFactory: FeeSelectorLogic.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase,
private val urlOpener: UrlOpener,
) : Model(), FeeSelectorModelCallback {
private val params = paramsContainer.require<FeeSelectorParams.FeeSelectorBlockParams>()
private val feeSelectorLogic = feeSelectorLogicFactory.create(
params = params,
modelScope = modelScope,
)
val feeSelectorBottomSheet = SlotNavigation<Unit>()
val uiState: StateFlow<FeeSelectorUM>
field = feeSelectorLogic.uiState
override fun onFeeResult(feeSelectorUM: FeeSelectorUM) {
uiState.value = feeSelectorUM
feeSelectorBottomSheet.dismiss()
}
fun updateState(feeSelectorUM: FeeSelectorUM) {
uiState.value = feeSelectorUM
}
fun onReadMoreClicked() {
modelScope.launch {
urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee))
}
}
fun showFeeSelector() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.FeeScreenOpened(
categoryName = params.analyticsCategoryName,
source = params.analyticsSendSource,
),
)
analyticsEventHandler.send(
CommonSendAnalyticEvents.ScreenReopened(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Fee,
),
)
modelScope.launch {
neverShowTapHelpUseCase()
feeSelectorBottomSheet.activate(Unit)
}
}
}

View file

@ -1,9 +1,13 @@
package com.tangem.features.send.v2.feeselector.model
import androidx.compose.runtime.Stable
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeItem
@Stable
internal interface FeeSelectorIntents {
fun onFeeItemSelected(feeItem: FeeItem)
fun onTokenSelected(status: CryptoCurrencyStatus)
fun onCustomFeeValueChange(index: Int, value: String)
fun onNonceChange(value: String)
fun onDoneClick()
@ -11,6 +15,7 @@ internal interface FeeSelectorIntents {
internal class StubFeeSelectorIntents : FeeSelectorIntents {
override fun onFeeItemSelected(feeItem: FeeItem) {}
override fun onTokenSelected(status: CryptoCurrencyStatus) {}
override fun onCustomFeeValueChange(index: Int, value: String) {}
override fun onNonceChange(value: String) {}
override fun onDoneClick() {}

View file

@ -0,0 +1,322 @@
package com.tangem.features.send.v2.feeselector.model
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.raise.either
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.NonceInserted
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeNonce
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadListener
import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents
import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents.GasPriceInserter
import com.tangem.features.send.v2.feeselector.model.transformers.*
import com.tangem.utils.transformer.update
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class FeeSelectorLogic @AssistedInject constructor(
@Assisted private val params: FeeSelectorParams,
@Assisted private val modelScope: CoroutineScope,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val feeSelectorReloadListener: FeeSelectorReloadListener,
private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener,
private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger,
private val feeSelectorAlertFactory: FeeSelectorAlertFactory,
private val analyticsEventHandler: AnalyticsEventHandler,
private val sendFeatureToggles: SendFeatureToggles,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getAvailableFeeTokensUseCase: GetAvailableFeeTokensUseCase,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
) : FeeSelectorIntents {
private var appCurrency: AppCurrency = AppCurrency.Default
val uiState = MutableStateFlow<FeeSelectorUM>(params.state)
val isGaslessEnabled = sendFeatureToggles.isGaslessTransactionsEnabled &&
params.onLoadFeeExtended != null &&
isGaslessFeeSupportedForNetwork(params.feeCryptoCurrencyStatus.currency.network) &&
params.cryptoCurrencyStatus.currency is CryptoCurrency.Token
init {
initAppCurrency()
subscribeOnFeeReloadTriggerUpdates()
subscribeOnFeeCheckReloadTriggerUpdates()
subscribeOnFeeLoadingStateTriggerUpdates()
loadFee()
}
private fun initAppCurrency() {
modelScope.launch {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
}
}
private fun loadFee() {
if (uiState.value is FeeSelectorUM.Error) {
return
}
if (uiState.value !is FeeSelectorUM.Content) {
uiState.update(FeeSelectorLoadingTransformer)
}
modelScope.launch {
callLoadFee()
.fold(
ifLeft = { error -> uiState.update(FeeSelectorErrorTransformer(error)) },
ifRight = { fee ->
uiState.update(
FeeSelectorLoadedTransformer(
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = fee.selectedTokenOrNull ?: params.feeCryptoCurrencyStatus,
appCurrency = appCurrency,
fees = fee,
feeStateConfiguration = params.feeStateConfiguration,
isFeeApproximate = isFeeApproximate(fee.transactionFee.normal.amount.type),
feeSelectorIntents = this@FeeSelectorLogic,
),
)
},
)
}
}
private fun isFeeApproximate(amountType: AmountType): Boolean {
val networkId = params.feeCryptoCurrencyStatus.currency.network.id
return isFeeApproximateUseCase(networkId = networkId, amountType = amountType)
}
override fun onFeeItemSelected(feeItem: FeeItem) {
if (feeItem is FeeItem.Custom) {
analyticsEventHandler.send(
CommonSendFeeAnalyticEvents.CustomFeeButtonClicked(categoryName = params.analyticsCategoryName),
)
}
uiState.update(FeeItemSelectedTransformer(feeItem))
}
override fun onTokenSelected(status: CryptoCurrencyStatus) {
uiState.update(FeeSelectorTokenSelectedTransformer(status))
loadFee()
}
override fun onCustomFeeValueChange(index: Int, value: String) {
uiState.update(
FeeSelectorCustomValueChangedTransformer(
index = index,
value = value,
intents = this,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
),
)
}
override fun onNonceChange(value: String) {
uiState.update(FeeSelectorNonceChangeTransformer(value = value))
}
override fun onDoneClick() {
val feeSelectorUM = uiState.value as? FeeSelectorUM.Content ?: return
analyticsEventHandler.send(
CommonSendFeeAnalyticEvents.SelectedFee(
categoryName = params.analyticsCategoryName,
feeType = feeSelectorUM.toAnalyticType(),
source = params.analyticsSendSource,
),
)
val isCustomFeeEdited = feeSelectorUM.selectedFeeItem.fee.amount.value != feeSelectorUM.fees.normal.amount.value
if (feeSelectorUM.selectedFeeItem is FeeItem.Custom && isCustomFeeEdited) {
analyticsEventHandler.send(GasPriceInserter(categoryName = params.analyticsCategoryName))
}
if (feeSelectorUM.feeNonce is FeeNonce.Nonce) {
analyticsEventHandler.send(
NonceInserted(
categoryName = params.analyticsCategoryName,
token = params.feeCryptoCurrencyStatus.currency.symbol,
blockchain = params.feeCryptoCurrencyStatus.currency.network.name,
),
)
}
}
private fun subscribeOnFeeReloadTriggerUpdates() {
feeSelectorReloadListener.reloadTriggerFlow
.onEach { data ->
if (data.isRemoveSuggestedFee) {
uiState.update(FeeSelectorRemoveSuggestedTransformer)
}
loadFee()
}
.launchIn(modelScope)
}
private fun subscribeOnFeeLoadingStateTriggerUpdates() {
feeSelectorReloadListener.loadingStateTriggerFlow
.onEach { uiState.update(FeeSelectorLoadingTransformer) }
.launchIn(modelScope)
}
private fun subscribeOnFeeCheckReloadTriggerUpdates() {
feeSelectorCheckReloadListener.checkReloadTriggerFlow
.onEach { checkLoadFee() }
.launchIn(modelScope)
}
private fun checkLoadFee() {
modelScope.launch {
callLoadFee().fold(
ifRight = { newFee ->
feeSelectorAlertFactory.getFeeUpdatedAlert(
newTransactionFee = newFee.transactionFee,
feeSelectorUM = uiState.value,
proceedAction = {
modelScope.launch {
feeSelectorCheckReloadTrigger.callbackCheckResult(true)
}
},
stopAction = {
modelScope.launch {
feeSelectorCheckReloadTrigger.callbackCheckResult(false)
}
},
)
},
ifLeft = { feeError ->
feeSelectorCheckReloadTrigger.callbackCheckResult(false)
feeSelectorAlertFactory.getFeeUnreachableErrorState(::loadFee)
},
)
}
}
@Suppress("UnreachableCode")
private suspend fun callLoadFee(): Either<GetFeeError, LoadedFeeResult> {
val extended = params.onLoadFeeExtended
if (extended == null || !isGaslessEnabled) {
return params.onLoadFee().map { LoadedFeeResult.Basic(it) }
}
val selectedTokenOrNull = (uiState.value as? FeeSelectorUM.Content)?.feeExtraInfo?.feeCryptoCurrencyStatus
if (selectedTokenOrNull?.currency is CryptoCurrency.Coin) {
return params.onLoadFee().flatMap { loadedFee ->
val feeExtended = TransactionFeeExtended(
transactionFee = loadedFee,
feeTokenId = selectedTokenOrNull.currency.id,
)
populateExtendedFee(feeExtended)
}
}
return extended(selectedTokenOrNull).fold(
ifLeft = { error ->
when (error) {
is GetFeeError.GaslessError.NotEnoughFunds -> error.left()
is GetFeeError.GaslessError -> {
// Something wrong with gasless fee, fallback to basic fee
params.onLoadFee().map { LoadedFeeResult.Basic(it) }
}
else -> error.left()
}
},
ifRight = { fee ->
populateExtendedFee(fee)
},
)
}
private suspend fun populateExtendedFee(
fee: TransactionFeeExtended,
): Either<GetFeeError, LoadedFeeResult.Extended> = either {
LoadedFeeResult.Extended(
fee = fee,
selectedToken = getSelectedTokenStatus(fee.feeTokenId).bind(),
availableTokens = getAvailableFeeTokens().bind(),
)
}
private suspend fun getSelectedTokenStatus(tokenId: CryptoCurrency.ID): Either<GetFeeError, CryptoCurrencyStatus> =
either {
val feeTokenId = tokenId
if (params.feeCryptoCurrencyStatus.currency.id != feeTokenId) {
getSingleCryptoCurrencyStatusUseCase
.invokeMultiWalletSync(
userWalletId = params.userWalletId,
cryptoCurrencyId = feeTokenId,
).getOrElse {
raise(GetFeeError.DataError(IllegalStateException("No token found for id: $tokenId")))
}
} else {
params.feeCryptoCurrencyStatus
}
}
private suspend fun getAvailableFeeTokens(): Either<GetFeeError, List<CryptoCurrencyStatus>> = either {
val userWallet = getUserWalletUseCase(params.userWalletId).mapLeft {
GetFeeError.DataError(IllegalStateException("No wallet found for id: ${params.userWalletId}"))
}.bind()
getAvailableFeeTokensUseCase.invoke(
userWallet = userWallet,
network = params.cryptoCurrencyStatus.currency.network,
).bind()
}
sealed class LoadedFeeResult {
data class Extended(
val fee: TransactionFeeExtended,
val selectedToken: CryptoCurrencyStatus?,
val availableTokens: List<CryptoCurrencyStatus>,
) : LoadedFeeResult()
data class Basic(val fee: TransactionFee) : LoadedFeeResult()
val selectedTokenOrNull: CryptoCurrencyStatus?
get() = when (this) {
is Extended -> selectedToken
is Basic -> null
}
val transactionFee: TransactionFee
get() = when (this) {
is Extended -> fee.transactionFee
is Basic -> fee
}
}
@AssistedFactory
interface Factory {
fun create(params: FeeSelectorParams, modelScope: CoroutineScope): FeeSelectorLogic
}
}

View file

@ -1,246 +1,93 @@
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.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.AmountType
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.NonceInserted
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.core.decompose.utils.stack
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeNonce
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadListener
import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents
import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents.GasPriceInserter
import com.tangem.features.send.v2.feeselector.model.transformers.*
import com.tangem.utils.TangemBlogUrlBuilder
import com.tangem.features.send.v2.feeselector.route.FeeSelectorRoute
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 com.tangem.utils.extensions.isSingleItem
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class FeeSelectorModel @Inject constructor(
paramsContainer: ParamsContainer,
private val urlOpener: UrlOpener,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase,
private val feeSelectorReloadListener: FeeSelectorReloadListener,
private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener,
private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger,
private val feeSelectorAlertFactory: FeeSelectorAlertFactory,
feeSelectorLogicFactory: FeeSelectorLogic.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model(), FeeSelectorIntents, FeeSelectorModelCallback {
) : Model(), FeeSelectorIntents {
private val params = paramsContainer.require<FeeSelectorParams>()
private var appCurrency: AppCurrency = AppCurrency.Default
private val params = paramsContainer.require<FeeSelectorParams.FeeSelectorDetailsParams>()
private val feeSelectorLogic = feeSelectorLogicFactory.create(
params = params,
modelScope = modelScope,
)
val feeSelectorBottomSheet = SlotNavigation<Unit>()
val uiState: StateFlow<FeeSelectorUM>
field = MutableStateFlow<FeeSelectorUM>(params.state)
init {
initAppCurrency()
subscribeOnFeeReloadTriggerUpdates()
subscribeOnFeeCheckReloadTriggerUpdates()
subscribeOnFeeLoadingStateTriggerUpdates()
loadFee()
}
fun updateState(feeSelectorUM: FeeSelectorUM) {
uiState.value = feeSelectorUM
}
fun onReadMoreClicked() {
urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK)
}
private fun initAppCurrency() {
modelScope.launch {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
}
}
private fun loadFee() {
if (uiState.value !is FeeSelectorUM.Content) {
uiState.update(FeeSelectorLoadingTransformer)
}
modelScope.launch {
params.onLoadFee()
.fold(
ifLeft = { error -> uiState.update(FeeSelectorErrorTransformer(error)) },
ifRight = { fee ->
uiState.update(
FeeSelectorLoadedTransformer(
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
appCurrency = appCurrency,
fees = fee,
feeStateConfiguration = params.feeStateConfiguration,
isFeeApproximate = isFeeApproximate(fee.normal.amount.type),
feeSelectorIntents = this@FeeSelectorModel,
),
)
},
)
}
}
private fun isFeeApproximate(amountType: AmountType): Boolean {
val networkId = params.feeCryptoCurrencyStatus.currency.network.id
return isFeeApproximateUseCase(networkId = networkId, amountType = amountType)
}
val stackNavigation = StackNavigation<FeeSelectorRoute>()
val uiState = feeSelectorLogic.uiState
override fun onFeeItemSelected(feeItem: FeeItem) {
if (feeItem is FeeItem.Custom) {
analyticsEventHandler.send(
CommonSendFeeAnalyticEvents.CustomFeeButtonClicked(categoryName = params.analyticsCategoryName),
)
feeSelectorLogic.onFeeItemSelected(feeItem)
if (contentState().selectedFeeItem is FeeItem.Custom) {
return
}
uiState.update(FeeItemSelectedTransformer(feeItem))
if (feeItem !is FeeItem.Custom) {
onDoneClick()
modelScope.launch {
val stack = stackNavigation.stack()
if (stack.isSingleItem() && stack[0] is FeeSelectorRoute.ChooseSpeed) {
// In case of only speed selection, we finish the flow immediately after selection
feeSelectorLogic.onDoneClick()
params.callback.onFeeResult(uiState.value)
} else {
stackNavigation.pop()
}
}
}
override fun onTokenSelected(status: CryptoCurrencyStatus) {
feeSelectorLogic.onTokenSelected(status)
stackNavigation.pop()
}
override fun onCustomFeeValueChange(index: Int, value: String) {
uiState.update(
FeeSelectorCustomValueChangedTransformer(
index = index,
value = value,
intents = this,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
),
)
feeSelectorLogic.onCustomFeeValueChange(index, value)
}
override fun onNonceChange(value: String) {
uiState.update(FeeSelectorNonceChangeTransformer(value = value))
feeSelectorLogic.onNonceChange(value)
}
override fun onDoneClick() {
val feeSelectorUM = uiState.value as? FeeSelectorUM.Content ?: return
analyticsEventHandler.send(
CommonSendFeeAnalyticEvents.SelectedFee(
categoryName = params.analyticsCategoryName,
feeType = feeSelectorUM.toAnalyticType(),
source = params.analyticsSendSource,
),
)
val isCustomFeeEdited = feeSelectorUM.selectedFeeItem.fee.amount.value != feeSelectorUM.fees.normal.amount.value
if (feeSelectorUM.selectedFeeItem is FeeItem.Custom && isCustomFeeEdited) {
analyticsEventHandler.send(GasPriceInserter(categoryName = params.analyticsCategoryName))
}
if (feeSelectorUM.feeNonce is FeeNonce.Nonce) {
analyticsEventHandler.send(
NonceInserted(
categoryName = params.analyticsCategoryName,
token = params.feeCryptoCurrencyStatus.currency.symbol,
blockchain = params.feeCryptoCurrencyStatus.currency.network.name,
),
)
}
(params as? FeeSelectorParams.FeeSelectorDetailsParams)?.callback?.onFeeResult(uiState.value)
}
override fun onFeeResult(feeSelectorUM: FeeSelectorUM) {
uiState.value = feeSelectorUM
feeSelectorBottomSheet.dismiss()
}
fun showFeeSelector() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.FeeScreenOpened(
categoryName = params.analyticsCategoryName,
source = params.analyticsSendSource,
),
)
analyticsEventHandler.send(
CommonSendAnalyticEvents.ScreenReopened(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Fee,
),
)
modelScope.launch {
neverShowTapHelpUseCase()
feeSelectorBottomSheet.activate(Unit)
}
}
private fun subscribeOnFeeReloadTriggerUpdates() {
feeSelectorReloadListener.reloadTriggerFlow
.onEach { data ->
if (data.isRemoveSuggestedFee) {
uiState.update(FeeSelectorRemoveSuggestedTransformer)
}
loadFee()
val stack = stackNavigation.stack()
if (stack.isSingleItem()) {
feeSelectorLogic.onDoneClick()
params.callback.onFeeResult(uiState.value)
} else {
stackNavigation.pop()
}
.launchIn(modelScope)
}
}
private fun subscribeOnFeeLoadingStateTriggerUpdates() {
feeSelectorReloadListener.loadingStateTriggerFlow
.onEach { uiState.update(FeeSelectorLoadingTransformer) }
.launchIn(modelScope)
private fun contentState(): FeeSelectorUM.Content {
val currentState = uiState.value
require(currentState is FeeSelectorUM.Content) { "Current state must be FeeSelectorUM.Content" }
return currentState
}
private fun subscribeOnFeeCheckReloadTriggerUpdates() {
feeSelectorCheckReloadListener.checkReloadTriggerFlow
.onEach { checkLoadFee() }
.launchIn(modelScope)
}
private fun checkLoadFee() {
modelScope.launch {
params.onLoadFee().fold(
ifRight = { newFee ->
feeSelectorAlertFactory.getFeeUpdatedAlert(
newTransactionFee = newFee,
feeSelectorUM = uiState.value,
proceedAction = {
modelScope.launch {
feeSelectorCheckReloadTrigger.callbackCheckResult(true)
}
},
stopAction = {
modelScope.launch {
feeSelectorCheckReloadTrigger.callbackCheckResult(false)
}
},
)
},
ifLeft = { feeError ->
feeSelectorCheckReloadTrigger.callbackCheckResult(false)
feeSelectorAlertFactory.getFeeUnreachableErrorState(::loadFee)
},
)
fun getInitialRoute(): FeeSelectorRoute {
return when {
feeSelectorLogic.isGaslessEnabled -> FeeSelectorRoute.NetworkFee
else -> FeeSelectorRoute.ChooseSpeed
}
}
}

View file

@ -7,6 +7,13 @@ import com.tangem.utils.transformer.Transformer
internal class FeeSelectorErrorTransformer(private val error: GetFeeError) : Transformer<FeeSelectorUM> {
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
if (prevState is FeeSelectorUM.Content && error is GetFeeError.GaslessError.NotEnoughFunds) {
return prevState.copy(
isPrimaryButtonEnabled = false,
feeExtraInfo = prevState.feeExtraInfo.copy(isNotEnoughFunds = true),
)
}
return FeeSelectorUM.Error(error = error)
}
}

View file

@ -1,23 +1,24 @@
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.models.currency.CryptoCurrencyStatus
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.FeeSelectorLogic.LoadedFeeResult
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList")
internal class FeeSelectorLoadedTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val fees: TransactionFee,
private val fees: LoadedFeeResult,
private val feeStateConfiguration: FeeSelectorParams.FeeStateConfiguration,
private val isFeeApproximate: Boolean,
private val feeSelectorIntents: FeeSelectorIntents,
@ -25,7 +26,7 @@ internal class FeeSelectorLoadedTransformer(
private val feeItemsConverter = FeeItemConverter(
feeStateConfiguration = feeStateConfiguration,
normalFee = fees.normal,
normalFee = fees.transactionFee.normal,
feeSelectorIntents = feeSelectorIntents,
appCurrency = appCurrency,
cryptoCurrencyStatus = feeCryptoCurrencyStatus,
@ -37,10 +38,17 @@ internal class FeeSelectorLoadedTransformer(
} else {
null
}
val feeItems: ImmutableList<FeeItem> = feeItemsConverter.convert(FeeItemConverter.Input(fees, prevCustomFee))
val feeItems: ImmutableList<FeeItem> = feeItemsConverter.convert(
FeeItemConverter.Input(fees.transactionFee, prevCustomFee),
)
val selectedFee = when (prevState) {
is FeeSelectorUM.Content -> feeItems.first { it.isSameClass(prevState.selectedFeeItem) }
is FeeSelectorUM.Content -> if (prevState.selectedFeeItem is FeeItem.Loading) {
feeItems.firstOrNull { it is FeeItem.Market }
} else {
feeItems.firstOrNull { it.isSameClass(prevState.selectedFeeItem) }
} ?: FeeItem.Loading
is FeeSelectorUM.Error,
FeeSelectorUM.Loading,
-> feeItems.find { it is FeeItem.Suggested } ?: feeItems.first { it is FeeItem.Market }
@ -49,8 +57,8 @@ internal class FeeSelectorLoadedTransformer(
val nonce = ((prevState as? FeeSelectorUM.Content)?.feeNonce as? FeeNonce.Nonce)?.nonce
return FeeSelectorUM.Content(
isPrimaryButtonEnabled = true,
fees = fees,
isPrimaryButtonEnabled = selectedFee !is FeeItem.Loading,
fees = fees.transactionFee,
feeItems = feeItems,
selectedFeeItem = selectedFee,
feeExtraInfo = FeeExtraInfo(
@ -58,6 +66,9 @@ internal class FeeSelectorLoadedTransformer(
isFeeConvertibleToFiat = feeCryptoCurrencyStatus.currency.network.hasFiatFeeRate,
isTronToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token &&
isTron(cryptoCurrencyStatus.currency.network.rawId),
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
availableFeeCurrencies = getAvailableFeeCurrencies(),
transactionFeeExtended = (fees as? LoadedFeeResult.Extended)?.fee,
),
feeFiatRateUM = feeCryptoCurrencyStatus.value.fiatRate?.let { rate ->
FeeFiatRateUM(
@ -65,7 +76,7 @@ internal class FeeSelectorLoadedTransformer(
appCurrency = appCurrency,
)
},
feeNonce = if (fees.normal is Fee.Ethereum) {
feeNonce = if (fees.transactionFee.normal is Fee.Ethereum) {
FeeNonce.Nonce(
nonce = nonce,
onNonceChange = feeSelectorIntents::onNonceChange,
@ -75,4 +86,9 @@ internal class FeeSelectorLoadedTransformer(
},
)
}
private fun getAvailableFeeCurrencies(): ImmutableList<CryptoCurrencyStatus>? {
if (fees !is LoadedFeeResult.Extended) return null
return fees.availableTokens.toImmutableList()
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.domain.models.currency.CryptoCurrencyStatus
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.persistentListOf
class FeeSelectorTokenSelectedTransformer(
private val selectedToken: CryptoCurrencyStatus,
) : Transformer<FeeSelectorUM> {
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
return if (prevState is FeeSelectorUM.Content) {
prevState.copy(
isPrimaryButtonEnabled = false,
selectedFeeItem = FeeItem.Loading,
feeItems = persistentListOf(FeeItem.Loading),
feeExtraInfo = prevState.feeExtraInfo.copy(
feeCryptoCurrencyStatus = selectedToken,
isNotEnoughFunds = false,
),
)
} else {
prevState
}
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.send.v2.feeselector.route
import androidx.compose.runtime.Immutable
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.send.v2.impl.R
import kotlinx.serialization.Serializable
@Immutable
@Serializable
internal sealed interface FeeSelectorRoute : Route {
val title: TextReference
@Serializable
data object NetworkFee : FeeSelectorRoute {
override val title: TextReference = resourceReference(R.string.common_network_fee_title)
}
@Serializable
data object ChooseSpeed : FeeSelectorRoute {
override val title: TextReference = resourceReference(R.string.fee_selector_choose_speed_title)
}
@Serializable
data object ChooseToken : FeeSelectorRoute {
override val title: TextReference = resourceReference(R.string.fee_selector_choose_token_title)
}
}

View file

@ -28,7 +28,11 @@ 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.components.audits.AuditLabel
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.tooltip.TangemTooltip
import com.tangem.core.ui.extensions.annotatedReference
import com.tangem.core.ui.extensions.stringReference
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
@ -38,6 +42,9 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.FeeSelectorBlockTestTags
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.impl.R
@ -50,9 +57,14 @@ private const val READ_MORE_TAG = "READ_MORE"
@Composable
internal fun FeeSelectorBlockContent(
state: FeeSelectorUM,
isGaslessFeatureEnabled: Boolean,
onReadMoreClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state is FeeSelectorUM.Error && state.isHidden) {
return
}
Row(
modifier = modifier
.background(TangemTheme.colors.background.action)
@ -68,16 +80,25 @@ internal fun FeeSelectorBlockContent(
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
FeeSelectorDescription(state = state, onReadMoreClick = onReadMoreClick)
FeeSelectorDescription(
state = state,
isGaslessFeatureEnabled = isGaslessFeatureEnabled,
onReadMoreClick = onReadMoreClick,
)
}
}
@Composable
private fun FeeSelectorDescription(state: FeeSelectorUM, onReadMoreClick: () -> Unit, modifier: Modifier = Modifier) {
private fun FeeSelectorDescription(
state: FeeSelectorUM,
isGaslessFeatureEnabled: Boolean,
onReadMoreClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween) {
FeeSelectorStaticPart(modifier = Modifier.weight(1f), onReadMoreClick = onReadMoreClick)
when (state) {
is FeeSelectorUM.Content -> FeeContent(state)
is FeeSelectorUM.Content -> FeeContent(state, isGaslessFeatureEnabled)
is FeeSelectorUM.Loading -> FeeLoading()
is FeeSelectorUM.Error -> FeeError()
}
@ -122,7 +143,7 @@ private fun FeeSelectorStaticPart(onReadMoreClick: () -> Unit, modifier: Modifie
}
}
TangemTooltip(
text = annotatedString,
text = annotatedReference(annotatedString),
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing6)
.size(TangemTheme.dimens.size16)
@ -160,9 +181,18 @@ private fun FeeLoading() {
}
@Composable
private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifier) {
private fun FeeContent(state: FeeSelectorUM.Content, isGaslessFeatureEnabled: Boolean, modifier: Modifier = Modifier) {
val fiatRate = state.feeFiatRateUM
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
if (isGaslessFeatureEnabled) {
AuditLabel(
state = AuditLabelUM(
text = stringReference(state.selectedFeeItem.fee.amount.currencySymbol),
type = AuditLabelUM.Type.General,
),
)
}
EllipsisText(
text = if (state.feeExtraInfo.isFeeConvertibleToFiat && fiatRate != null) {
getFiatString(
@ -187,7 +217,10 @@ private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifi
.padding(start = TangemTheme.dimens.spacing4)
.testTag(FeeSelectorBlockTestTags.FEE_AMOUNT),
)
if (!state.feeItems.isSingleItem()) {
val isGaslessAvailable = isGaslessFeatureEnabled && state.feeExtraInfo.transactionFeeExtended != null
if (!state.feeItems.isSingleItem() || isGaslessAvailable) {
Icon(
modifier = Modifier
.size(width = 18.dp, height = 24.dp)
@ -205,16 +238,58 @@ private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifi
@Composable
private fun FeeSelectorBlockContent_Preview(@PreviewParameter(FeeSelectorUMProvider::class) state: FeeSelectorUM) {
TangemThemePreview {
FeeSelectorBlockContent(modifier = Modifier.fillMaxWidth(), state = state, onReadMoreClick = {})
FeeSelectorBlockContent(
modifier = Modifier.fillMaxWidth(),
isGaslessFeatureEnabled = true,
state = state,
onReadMoreClick = {},
)
}
}
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)))
private val maxFeeItem
get() = FeeItem.Market(
fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Hedera)),
)
private val lowFeeItem
get() = FeeItem.Market(
Fee.Common(
amount = Amount(
value = BigDecimal("0.0002876"),
blockchain = Blockchain.Ethereum,
),
),
)
val cryptoCurrencyStatus
get() = CryptoCurrencyStatus(
currency = CryptoCurrency.Coin(
id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"),
network = Network(
id = Network.ID(
value = "bitcoin",
derivationPath = Network.DerivationPath.None,
),
backendId = "bitcoin",
name = "Bitcoin",
currencySymbol = "BTC",
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("bitcoin"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
),
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png",
isCustom = false,
),
value = CryptoCurrencyStatus.Loading,
)
override val values: Sequence<FeeSelectorUM> = sequenceOf(
FeeSelectorUM.Content(
@ -225,6 +300,7 @@ private class FeeSelectorUMProvider : PreviewParameterProvider<FeeSelectorUM> {
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeNonce = FeeNonce.None,
feeFiatRateUM = FeeFiatRateUM(
@ -241,6 +317,7 @@ private class FeeSelectorUMProvider : PreviewParameterProvider<FeeSelectorUM> {
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeNonce = FeeNonce.None,
feeFiatRateUM = FeeFiatRateUM(

View file

@ -1,67 +1,41 @@
package com.tangem.features.send.v2.feeselector.ui
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
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.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
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.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
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
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe
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.*
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.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.feeselector.route.FeeSelectorRoute
import com.tangem.features.send.v2.impl.R
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Suppress("LongParameterList")
@Composable
internal fun FeeSelectorModalBottomSheet(
childStack: ChildStack<FeeSelectorRoute, ComposableContentComponent>,
state: FeeSelectorUM,
feeSelectorIntents: FeeSelectorIntents,
feeDisplaySource: FeeSelectorParams.FeeDisplaySource,
onDismiss: () -> Unit,
onBack: () -> Unit,
) {
if (state !is FeeSelectorUM.Content) return
@ -71,426 +45,98 @@ internal fun FeeSelectorModalBottomSheet(
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors.background.primary,
containerColor = TangemTheme.colors.background.tertiary,
onBack = if (childStack.backStack.isNotEmpty()) onBack else null,
title = {
FeeTitle(feeDisplaySource = feeDisplaySource, onDismiss = onDismiss)
},
content = {
FeeSelectorItems(
state = state,
feeSelectorIntents = feeSelectorIntents,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp),
FeeTitle(
childStack = childStack,
feeDisplaySource = feeDisplaySource,
onBack = onBack,
)
},
footer = if (state.selectedFeeItem is FeeItem.Custom) {
{
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
enabled = state.isPrimaryButtonEnabled,
text = stringResourceSafe(R.string.common_done),
onClick = feeSelectorIntents::onDoneClick,
)
content = {
Content(childStack = childStack)
},
footer = when {
childStack.active.configuration is FeeSelectorRoute.NetworkFee -> {
{
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
enabled = state.isPrimaryButtonEnabled,
text = stringResourceSafe(R.string.common_confirm),
onClick = feeSelectorIntents::onDoneClick,
)
}
}
} else {
null
childStack.active.configuration is FeeSelectorRoute.ChooseSpeed &&
state.selectedFeeItem is FeeItem.Custom -> {
{
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
enabled = state.isPrimaryButtonEnabled,
text = stringResourceSafe(R.string.common_done),
onClick = feeSelectorIntents::onDoneClick,
)
}
}
else -> null
},
)
}
@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,
)
}
}
}
@Composable
private fun FeeSelectorItems(
state: FeeSelectorUM.Content,
feeSelectorIntents: FeeSelectorIntents,
modifier: Modifier = Modifier,
private fun FeeTitle(
childStack: ChildStack<FeeSelectorRoute, ComposableContentComponent>,
feeDisplaySource: FeeSelectorParams.FeeDisplaySource,
onBack: () -> Unit,
) {
Column(modifier = modifier) {
val feeFiatRateUM = state.feeFiatRateUM
state.feeItems.fastForEachIndexed { index, item ->
val isSelected = item.isSameClass(state.selectedFeeItem)
val isLastItem = index == state.feeItems.size - 1
val iconTint by animateColorAsState(
targetValue = if (isSelected) TangemTheme.colors.icon.accent else TangemTheme.colors.text.tertiary,
label = "Fee selector icon tint change",
)
val iconBackgroundColor by animateColorAsState(
targetValue = if (isSelected) {
TangemTheme.colors.icon.accent.copy(alpha = 0.1F)
Children(
stack = childStack,
animation = stackAnimation(fade(animationSpec = tween(durationMillis = 100))),
) { current ->
when (feeDisplaySource) {
FeeSelectorParams.FeeDisplaySource.Screen -> {
if (childStack.backStack.isNotEmpty()) {
TangemModalBottomSheetTitle(
title = current.configuration.title,
startIconRes = R.drawable.ic_back_24,
onStartClick = onBack,
)
} else {
TangemTheme.colors.background.secondary
},
label = "Fee selector icon background change",
)
val itemModifier = Modifier
.fillMaxWidth()
.selectedBorder(isSelected = isSelected)
.clickableSingle(onClick = { feeSelectorIntents.onFeeItemSelected(item) })
when (item) {
is FeeItem.Custom -> CustomFeeBlock(
modifier = itemModifier,
customFee = item,
isSelected = isSelected,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
onValueChange = feeSelectorIntents::onCustomFeeValueChange,
nonce = state.feeNonce,
)
else -> RegularFeeItemContent(
modifier = itemModifier,
title = item.title,
iconRes = item.iconRes,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
preDot = stringReference(
item.fee.amount.value.format {
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (state.feeExtraInfo.isFeeConvertibleToFiat && feeFiatRateUM != null) {
getFiatReference(
value = item.fee.amount.value,
rate = feeFiatRateUM.rate,
appCurrency = feeFiatRateUM.appCurrency,
)
} else {
null
},
ellipsizeOffset = item.fee.amount.currencySymbol.length,
showDivider = !isSelected && !isLastItem,
TangemModalBottomSheetTitle(
title = current.configuration.title,
endIconRes = R.drawable.ic_close_24,
onEndClick = onBack,
)
}
}
FeeSelectorParams.FeeDisplaySource.BottomSheet -> {
TangemModalBottomSheetTitle(
title = current.configuration.title,
startIconRes = R.drawable.ic_back_24,
onStartClick = onBack,
)
}
}
}
}
@Suppress("LongParameterList")
@Composable
private fun CustomFeeBlock(
customFee: FeeItem.Custom,
isSelected: Boolean,
iconBackgroundColor: Color,
iconTint: Color,
onValueChange: (Int, String) -> Unit,
nonce: FeeNonce,
private fun Content(
childStack: ChildStack<FeeSelectorRoute, ComposableContentComponent>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier.padding(all = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.size(36.dp)
.background(color = iconBackgroundColor, shape = CircleShape)
.padding(6.dp),
painter = painterResource(R.drawable.ic_edit_v2_24),
tint = iconTint,
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.common_custom),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
)
}
AnimatedVisibility(
visible = isSelected,
label = "Custom Fee Selected Animation",
enter = expandVertically().plus(fadeIn()),
exit = shrinkVertically().plus(fadeOut()),
) {
ExpandedCustomFeeItems(
customFeeFields = customFee.customValues,
onValueChange = onValueChange,
nonce = nonce,
)
}
Children(
stack = childStack,
animation = stackAnimation(fade(animationSpec = tween(durationMillis = 100))),
modifier = modifier
.fillMaxSize()
.animateContentSize(),
) {
it.instance.Content(Modifier.fillMaxSize())
}
}
@Composable
private fun ExpandedCustomFeeItems(
customFeeFields: ImmutableList<CustomFeeFieldUM>,
onValueChange: (Int, String) -> Unit,
nonce: FeeNonce,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
customFeeFields.fastForEachIndexed { index, field ->
val isShowDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce
if (field.label != null) {
InputRowEnterInfoAmountV2(
text = field.value,
decimals = field.decimals,
symbol = field.symbol,
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
info = field.label,
description = field.footer,
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
onValueChange = { onValueChange(index, it) },
showDivider = isShowDivider,
isReadOnly = field.isReadonly,
)
} else {
InputRowEnterInfoAmountV2(
text = field.value,
decimals = field.decimals,
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
symbol = field.symbol,
description = field.footer,
onValueChange = { onValueChange(index, it) },
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
showDivider = isShowDivider,
)
}
}
if (nonce is FeeNonce.Nonce) {
InputRowEnter(
text = nonce.nonce?.toString().orEmpty(),
title = resourceReference(R.string.send_nonce),
description = resourceReference(R.string.send_nonce_footer),
onValueChange = nonce.onNonceChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
placeholder = resourceReference(R.string.send_nonce_hint),
titleColor = TangemTheme.colors.text.secondary,
showDivider = false,
modifier = Modifier
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
}
@Composable
private fun RegularFeeItemContent(
title: TextReference,
@DrawableRes iconRes: Int,
iconBackgroundColor: Color,
iconTint: Color,
modifier: Modifier = Modifier,
preDot: TextReference? = null,
postDot: TextReference? = null,
ellipsizeOffset: Int? = null,
showDivider: Boolean = true,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier.padding(all = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.size(36.dp)
.background(color = iconBackgroundColor, shape = CircleShape)
.padding(6.dp),
painter = painterResource(iconRes),
tint = iconTint,
contentDescription = null,
)
FeeDescription(
title = title,
preDot = preDot,
postDot = postDot,
ellipsizeOffset = ellipsizeOffset,
)
}
if (showDivider) {
HorizontalDivider(
modifier = Modifier.padding(start = 60.dp, end = 12.dp),
color = TangemTheme.colors.stroke.primary,
thickness = 0.5.dp,
)
}
}
}
@Composable
private fun FeeDescription(
title: TextReference,
modifier: Modifier = Modifier,
preDot: TextReference? = null,
postDot: TextReference? = null,
ellipsizeOffset: Int? = null,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = title.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
)
if (preDot != null) {
FeeValueContent(preDot = preDot, postDot = postDot, ellipsizeOffset = ellipsizeOffset)
}
}
}
@Composable
private fun FeeValueContent(preDot: TextReference, postDot: TextReference?, ellipsizeOffset: Int? = null) {
val ellipsis = if (ellipsizeOffset == null) {
TextEllipsis.End
} else {
TextEllipsis.OffsetEnd(ellipsizeOffset)
}
val textColor = TangemTheme.colors.text.tertiary
val textStyle = TangemTheme.typography.caption1
Row {
EllipsisText(
text = preDot.resolveReference(),
style = textStyle,
color = textColor,
textAlign = TextAlign.End,
ellipsis = ellipsis,
)
if (postDot != null) {
Text(
text = StringsSigns.DOT,
style = textStyle,
color = textColor,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing4),
)
Text(text = postDot.resolveReference(), style = textStyle, color = textColor)
}
}
}
@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 FeeSelectorBS_Preview(
@PreviewParameter(FeeSelectorUMContentProvider::class)
state: FeeSelectorUM.Content,
) {
TangemThemePreview {
FeeSelectorModalBottomSheet(
onDismiss = {},
state = state,
feeSelectorIntents = StubFeeSelectorIntents(),
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet,
)
}
}
private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider<FeeSelectorUM.Content>(
collection = listOf(
FeeSelectorUM.Content(
isPrimaryButtonEnabled = false,
feeItems = persistentListOf(
FeeItem.Suggested(
title = resourceReference(
id = R.string.wc_fee_suggested,
formatArgs = wrappedList("Tangem"),
),
fee = Fee.Common(Amount(value = BigDecimal("0.1"), blockchain = Blockchain.Ethereum)),
),
FeeItem.Slow(fee = Fee.Common(Amount(value = BigDecimal("0.01"), blockchain = Blockchain.Ethereum))),
FeeItem.Market(
fee = Fee.Common(
Amount(
value = BigDecimal("0.02"),
blockchain = Blockchain.Ethereum,
),
),
),
FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))),
customFeeItem,
),
selectedFeeItem = FeeItem.Slow(
fee = Fee.Common(
Amount(
value = BigDecimal("0.01"),
blockchain = Blockchain.Ethereum,
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = true,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal.TEN,
appCurrency = AppCurrency.Default,
),
feeNonce = FeeNonce.None,
fees = TransactionFee.Single(customFeeItem.fee),
),
),
)
private val customFeeItem = FeeItem.Custom(
fee = Fee.Common(Amount(value = BigDecimal("0.05"), blockchain = Blockchain.Ethereum)),
customValues = persistentListOf(
CustomFeeFieldUM(
value = "0.119806",
onValueChange = {},
keyboardOptions = KeyboardOptions(),
keyboardActions = KeyboardActions(),
symbol = "ETH",
decimals = 8,
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_custom_amount_fee_footer),
label = stringReference("~ 0,03 \$"),
isReadonly = false,
),
CustomFeeFieldUM(
value = "40000",
onValueChange = {},
keyboardOptions = KeyboardOptions(),
keyboardActions = KeyboardActions(),
symbol = "GWEI",
decimals = 8,
title = resourceReference(R.string.send_gas_price),
footer = resourceReference(R.string.send_gas_price_footer),
label = null,
isReadonly = false,
),
CustomFeeFieldUM(
value = "31400",
onValueChange = {},
keyboardOptions = KeyboardOptions(),
keyboardActions = KeyboardActions(),
symbol = null,
decimals = 8,
title = resourceReference(R.string.send_gas_limit),
footer = resourceReference(R.string.send_gas_limit_footer),
label = null,
isReadonly = false,
),
),
)
}

View file

@ -216,6 +216,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
callback = model,
predefinedValues = model.predefinedValues,
onLoadFee = model::loadFee,
onLoadFeeExtended = model::loadFeeExtended,
onSendTransaction = {
innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess)
},

View file

@ -15,6 +15,7 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
@ -86,12 +87,14 @@ internal class SendConfirmComponent(
params = FeeSelectorParams.FeeSelectorBlockParams(
state = model.uiState.value.feeSelectorUM,
onLoadFee = params.onLoadFee,
onLoadFeeExtended = params.onLoadFeeExtended,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeStateConfiguration = model.feeStateConfiguration,
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
analyticsCategoryName = params.analyticsCategoryName,
analyticsSendSource = params.analyticsSendSource,
userWalletId = params.userWallet.walletId,
),
onResult = model::onFeeResult,
)
@ -102,7 +105,6 @@ internal class SendConfirmComponent(
analyticsCategoryName = params.analyticsCategoryName,
userWalletId = params.userWallet.walletId,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
appCurrency = params.appCurrency,
callback = model,
notificationData = NotificationData(
@ -113,6 +115,7 @@ internal class SendConfirmComponent(
isIgnoreReduce = model.confirmData.isIgnoreReduce,
fee = model.confirmData.fee,
feeError = model.confirmData.feeError,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
),
),
)
@ -163,6 +166,7 @@ internal class SendConfirmComponent(
val isBalanceHidingFlow: StateFlow<Boolean>,
val predefinedValues: PredefinedValues,
val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
val onLoadFeeExtended: suspend (CryptoCurrencyStatus?) -> Either<GetFeeError, TransactionFeeExtended>,
val onSendTransaction: () -> Unit,
)

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer
import com.tangem.common.ui.amountScreen.models.AmountState
@ -29,6 +30,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.models.currency.CryptoCurrencyStatus
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
@ -36,6 +38,7 @@ import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.v2.api.SendNotificationsComponent
@ -111,6 +114,7 @@ internal class SendConfirmModel @Inject constructor(
private val accountsFeatureToggles: AccountsFeatureToggles,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val currenciesRepository: CurrenciesRepository,
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
sendBalanceUpdaterFactory: SendBalanceUpdater.Factory,
) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback {
@ -153,10 +157,7 @@ internal class SendConfirmModel @Inject constructor(
internal var feeStateConfiguration: FeeStateConfiguration = FeeStateConfiguration.None
init {
modelScope.launch {
isAmountSubtractAvailable =
isAmountSubtractAvailableUseCase(userWallet.walletId, cryptoCurrency).getOrElse { false }
}
updateAmountSubtractAvailability()
configConfirmNavigation()
subscribeOnNotificationsUpdateTrigger()
subscribeOnCheckFeeResultUpdates()
@ -355,7 +356,7 @@ internal class SendConfirmModel @Inject constructor(
val receivingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
cryptoCurrencyStatus = getCurrencyStatusForFeePayment(),
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = confirmData.reduceAmountBy.orZero(),
@ -388,11 +389,23 @@ internal class SendConfirmModel @Inject constructor(
}
private suspend fun sendTransaction(txData: TransactionData.Uncompiled) {
val result = sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = cryptoCurrency.network,
)
val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended
val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency
val result = if (isFeeInTokenCurrency) {
createAndSendGaslessTransactionUseCase(
userWallet = userWallet,
transactionData = txData,
fee = feeExtended,
)
} else {
sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = cryptoCurrency.network,
)
}
_uiState.update(SendConfirmSendingStateTransformer(isSending = false))
@ -412,8 +425,8 @@ internal class SendConfirmModel @Inject constructor(
),
)
},
ifRight = {
updateTransactionStatus(txData)
ifRight = { txHash ->
updateTransactionStatus(txData, txHash)
addTokenToWalletIfNeeded()
sendBalanceUpdater.scheduleUpdates()
sendAnalyticHelper.sendSuccessAnalytics(
@ -427,6 +440,16 @@ internal class SendConfirmModel @Inject constructor(
)
}
private fun getCurrencyStatusForFeePayment(): CryptoCurrencyStatus {
val feeExtended = feeUMV2?.feeExtraInfo?.transactionFeeExtended
val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency
return if (isFeeInTokenCurrency) {
feeUMV2?.feeExtraInfo?.feeCryptoCurrencyStatus ?: cryptoCurrencyStatus
} else {
params.feeCryptoCurrencyStatus
}
}
private fun addTokenToWalletIfNeeded() {
if (cryptoCurrency !is CryptoCurrency.Token) return
val wallets = destinationUM?.wallets ?: return
@ -456,9 +479,9 @@ internal class SendConfirmModel @Inject constructor(
}
}
private fun updateTransactionStatus(txData: TransactionData.Uncompiled) {
private fun updateTransactionStatus(txData: TransactionData.Uncompiled, txHash: String) {
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txData.hash.orEmpty(),
txHash = txHash.ifEmpty { txData.hash.orEmpty() },
networkId = cryptoCurrency.network.id,
currency = cryptoCurrency,
).getOrNull().orEmpty()
@ -498,6 +521,7 @@ internal class SendConfirmModel @Inject constructor(
isIgnoreReduce = confirmData.isIgnoreReduce,
fee = confirmData.fee,
feeError = confirmData.feeError,
feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment(),
),
)
_uiState.update { state ->
@ -599,9 +623,23 @@ internal class SendConfirmModel @Inject constructor(
override fun onFeeResult(feeSelectorUM: FeeSelectorUMRedesigned) {
sendIdleTimer = SystemClock.elapsedRealtime()
_uiState.update { it.copy(feeSelectorUM = feeSelectorUM) }
updateAmountSubtractAvailability()
updateConfirmNotifications()
}
private fun updateAmountSubtractAvailability() {
modelScope.launch {
val fee = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.transactionFee?.normal
// we assume if feeExtraInfo is empty then pay fee in the main currency
val feeTokenId = feeUMV2?.feeExtraInfo?.transactionFeeExtended?.feeTokenId ?: cryptoCurrency.id
isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(
userWalletId = userWallet.walletId,
currency = cryptoCurrency,
maybeGaslessFee = fee?.let { feeTokenId to fee },
).getOrElse { false }
}
}
private companion object {
const val CHECK_FEE_UPDATE_DELAY = 10_000L
}

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
@ -37,8 +38,11 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -95,6 +99,8 @@ internal class SendModel @Inject constructor(
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase,
private val getFeeForTokenUseCase: GetFeeForTokenUseCase,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val sendAmountUpdateTrigger: SendAmountUpdateTrigger,
@ -245,10 +251,10 @@ internal class SendModel @Inject constructor(
showAlertError()
}
suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
suspend fun prepareTransferTransaction(): Either<Throwable, TransactionData> {
val predefinedValues = predefinedValues
val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value
val transferTransaction = if (predefinedValues is PredefinedValues.Content.Deeplink) {
return if (predefinedValues is PredefinedValues.Content.Deeplink) {
val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull()
createTransferTransactionUseCase(
amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) ?: error("Invalid amount"),
@ -271,9 +277,12 @@ internal class SendModel @Inject constructor(
userWalletId = userWallet.walletId,
network = cryptoCurrency.network,
)
}.getOrElse {
return GetFeeError.DataError(it).left()
}
}
suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
val transferTransaction = prepareTransferTransaction()
.getOrElse { return GetFeeError.DataError(it).left() }
return getFeeUseCase(
transactionData = transferTransaction,
@ -282,6 +291,25 @@ internal class SendModel @Inject constructor(
)
}
suspend fun loadFeeExtended(maybeToken: CryptoCurrencyStatus?): Either<GetFeeError, TransactionFeeExtended> {
val transferTransaction = prepareTransferTransaction()
.getOrElse { return GetFeeError.DataError(it).left() }
return if (maybeToken == null) {
getFeeForGaslessUseCase(
transactionData = transferTransaction,
userWallet = userWallet,
network = params.currency.network,
)
} else {
getFeeForTokenUseCase(
transactionData = transferTransaction,
userWallet = userWallet,
token = maybeToken.currency,
)
}
}
fun showAlertError() {
sendConfirmAlertFactory.getGenericErrorState(
onFailedTxEmailClick = ::onFailedTxEmailClick,
@ -334,15 +362,15 @@ internal class SendModel @Inject constructor(
userWalletId = params.userWalletId,
currency = cryptoCurrency,
).onEach { (account, cryptoCurrencyStatus) ->
isAccountModeFlow.value = isAccountsModeEnabledUseCase.invokeSync()
accountFlow.value = account
cryptoCurrencyStatusFlow.value = cryptoCurrencyStatus
feeCryptoCurrencyStatusFlow.value = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
).getOrNull() ?: cryptoCurrencyStatus
isAccountModeFlow.value = isAccountsModeEnabledUseCase.invokeSync()
accountFlow.value = account
if (params.amount != null) {
router.replaceAll(Confirm)
}

View file

@ -21,7 +21,7 @@ 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.FeeBlock
import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
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
@ -106,7 +106,7 @@ private fun SuccessContent(
onClick = {},
)
destinationBlockComponent.Content(modifier = Modifier)
FeeBlock(feeSelectorUM = sendUM.feeSelectorUM)
FeeBlockSuccess(feeSelectorUM = sendUM.feeSelectorUM)
SpacerH(16.dp)
}
}

View file

@ -78,6 +78,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor(
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
analyticsCategoryName = params.analyticsCategoryName,
analyticsSendSource = params.analyticsSendSource,
userWalletId = params.userWallet.walletId,
),
onResult = model::onFeeResult,
)
@ -101,7 +102,6 @@ internal class NFTSendConfirmComponent @AssistedInject constructor(
analyticsCategoryName = params.analyticsCategoryName,
userWalletId = params.userWallet.walletId,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
appCurrency = params.appCurrency,
callback = model,
notificationData = SendNotificationsComponent.Params.NotificationData(
@ -112,6 +112,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor(
isIgnoreReduce = false,
fee = model.confirmData.fee,
feeError = model.confirmData.feeError,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
),
),
)

View file

@ -351,6 +351,7 @@ internal class NFTSendConfirmModel @Inject constructor(
isIgnoreReduce = false,
fee = confirmData.fee,
feeError = confirmData.feeError,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, // TODO change when gasless implement
),
)
}

View file

@ -21,7 +21,7 @@ import com.tangem.core.ui.utils.toPx
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.features.nft.component.NFTDetailsBlockComponent
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.v2.common.ui.FeeBlock
import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM
@ -109,7 +109,7 @@ private fun SuccessContent(
}
nftDetailsBlockComponent.Content(modifier = Modifier)
destinationBlockComponent.Content(modifier = Modifier)
FeeBlock(feeSelectorUM = nftSendUM.feeSelectorUM)
FeeBlockSuccess(feeSelectorUM = nftSendUM.feeSelectorUM)
SpacerH(16.dp)
}
}

View file

@ -75,7 +75,7 @@ private fun SendConvertTokenButton(onConvertToAnother: () -> Unit) {
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(20.dp)
.background(TangemTheme.colors.control.unchecked, CircleShape)
.background(TangemTheme.colors.control.default, CircleShape)
.padding(2.dp),
)
Text(

View file

@ -14,6 +14,7 @@ import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance
import com.tangem.features.send.v2.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class EthereumCustomFeeConverter(
@ -38,11 +39,16 @@ internal class EthereumCustomFeeConverter(
)
override fun convert(value: Fee.Ethereum): ImmutableList<CustomFeeFieldUM> {
if (value is Fee.Ethereum.TokenCurrency) {
return persistentListOf()
}
return buildList {
add(convertFeeValue(value))
addAll(
when (value) {
is Fee.Ethereum.TokenCurrency -> error("unreachable")
is Fee.Ethereum.EIP1559 -> eipFeeConverter.convert(value)
is Fee.Ethereum.Legacy -> legacyFeeConverter.convert(value)
},
@ -55,6 +61,7 @@ internal class EthereumCustomFeeConverter(
override fun convertBack(normalFee: Fee.Ethereum, value: ImmutableList<CustomFeeFieldUM>): Fee.Ethereum {
return when (normalFee) {
is Fee.Ethereum.TokenCurrency -> error("unreachable")
is Fee.Ethereum.EIP1559 -> eipFeeConverter.convertBack(normalFee = normalFee, value = value)
is Fee.Ethereum.Legacy -> legacyFeeConverter.convertBack(normalFee = normalFee, value = value)
}
@ -62,6 +69,7 @@ internal class EthereumCustomFeeConverter(
override fun getGasLimitIndex(feeValue: Fee.Ethereum): Int {
return when (feeValue) {
is Fee.Ethereum.TokenCurrency -> error("unreachable")
is Fee.Ethereum.EIP1559 -> eipFeeConverter.getGasLimitIndex(feeValue)
is Fee.Ethereum.Legacy -> legacyFeeConverter.getGasLimitIndex(feeValue)
}
@ -74,6 +82,7 @@ internal class EthereumCustomFeeConverter(
value: String,
): ImmutableList<CustomFeeFieldUM> {
return when (feeValue) {
is Fee.Ethereum.TokenCurrency -> error("unreachable")
is Fee.Ethereum.EIP1559 -> eipFeeConverter.onValueChange(
feeValue = feeValue,
customValues = customValues,

View file

@ -79,7 +79,7 @@ internal class NotificationsModel @Inject constructor(
private val analyticsCategoryName = params.analyticsCategoryName
private val userWalletId = params.userWalletId
private val cryptoCurrencyStatus = params.cryptoCurrencyStatus
private val feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus
private val feeCryptoCurrencyStatus = params.notificationData.feeCryptoCurrencyStatus
private val currency = cryptoCurrencyStatus.currency
private val appCurrency = params.appCurrency
@ -92,7 +92,10 @@ internal class NotificationsModel @Inject constructor(
init {
subscribeToNotificationUpdateTrigger()
checkIfSubtractAvailable()
modelScope.launch {
checkIfSubtractAvailable()
buildNotifications()
}
incrementNotificationsShowCount()
}
@ -102,11 +105,14 @@ internal class NotificationsModel @Inject constructor(
.launchIn(modelScope)
}
private fun checkIfSubtractAvailable() {
modelScope.launch {
isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(userWalletId, currency).getOrElse { false }
buildNotifications()
}
private suspend fun checkIfSubtractAvailable() {
val feeCurrencyId = notificationData.feeCryptoCurrencyStatus.currency.id
val fee = notificationData.fee
isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(
userWalletId = userWalletId,
currency = currency,
maybeGaslessFee = fee?.let { feeCurrencyId to fee },
).getOrElse { false }
}
private fun incrementNotificationsShowCount() {
@ -115,16 +121,19 @@ internal class NotificationsModel @Inject constructor(
}
}
private suspend fun updateState(data: NotificationData) {
private fun updateState(data: NotificationData) {
notificationData = data
buildNotifications()
modelScope.launch {
checkIfSubtractAvailable()
buildNotifications()
}
}
private suspend fun buildNotifications() = with(notificationData) {
val feeValue = fee?.amount?.value
val sendingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
cryptoCurrencyStatus = getCurrencyStatusForFeePayment(),
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
@ -226,7 +235,7 @@ internal class NotificationsModel @Inject constructor(
fee = feeValue,
userWalletId = userWalletId,
tokenStatus = cryptoCurrencyStatus,
coinStatus = feeCryptoCurrencyStatus,
feeStatus = notificationData.feeCryptoCurrencyStatus,
).getOrNull()
addExceedBalanceNotification(
@ -369,6 +378,15 @@ internal class NotificationsModel @Inject constructor(
}
}
private fun getCurrencyStatusForFeePayment(): CryptoCurrencyStatus {
val isFeeInTokenCurrency = notificationData.fee is Fee.Ethereum.TokenCurrency
return if (isFeeInTokenCurrency) {
notificationData.feeCryptoCurrencyStatus
} else {
cryptoCurrencyStatus
}
}
companion object {
const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3
}

View file

@ -8,6 +8,8 @@ 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.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2
@ -28,6 +30,34 @@ class NFTSendConfirmationNotificationsTransformerV2Test {
private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$")
private val analyticsCategoryName = "test_category"
val cryptoCurrencyStatus = CryptoCurrencyStatus(
currency = CryptoCurrency.Coin(
id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"),
network = Network(
id = Network.ID(
value = "bitcoin",
derivationPath = Network.DerivationPath.None,
),
backendId = "bitcoin",
name = "Bitcoin",
currencySymbol = "BTC",
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("bitcoin"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
),
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png",
isCustom = false,
),
value = CryptoCurrencyStatus.Loading,
)
@Test
fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest {
// GIVEN
@ -191,6 +221,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test {
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
@ -274,6 +305,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test {
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
@ -345,6 +377,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test {
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
@ -428,6 +461,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test {
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),

View file

@ -10,6 +10,8 @@ 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.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import io.mockk.mockk
@ -30,6 +32,34 @@ class SendConfirmationNotificationsTransformerV2Test {
private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$")
private val analyticsCategoryName = "test_category"
val cryptoCurrencyStatus = CryptoCurrencyStatus(
currency = CryptoCurrency.Coin(
id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"),
network = Network(
id = Network.ID(
value = "bitcoin",
derivationPath = Network.DerivationPath.None,
),
backendId = "bitcoin",
name = "Bitcoin",
currencySymbol = "BTC",
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("bitcoin"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
),
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/medium/bitcoin.png",
isCustom = false,
),
value = CryptoCurrencyStatus.Loading,
)
@Test
fun `GIVEN non content state WHEN transform THEN returns original state`() = runTest {
// GIVEN
@ -247,6 +277,7 @@ class SendConfirmationNotificationsTransformerV2Test {
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
@ -330,6 +361,7 @@ class SendConfirmationNotificationsTransformerV2Test {
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
@ -401,6 +433,7 @@ class SendConfirmationNotificationsTransformerV2Test {
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),
@ -484,6 +517,7 @@ class SendConfirmationNotificationsTransformerV2Test {
isFeeApproximate = false,
isFeeConvertibleToFiat = false,
isTronToken = false,
feeCryptoCurrencyStatus = cryptoCurrencyStatus,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("50000"),