Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-07 18:10:46 +05:00
parent b01b0ef4ca
commit 410ecb6fc0
14 changed files with 311 additions and 195 deletions

View file

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

View file

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

View file

@ -2,14 +2,16 @@ package com.tangem.features.send.v2.feeselector.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.AmountType
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
@ -30,13 +32,14 @@ internal class FeeSelectorModel @Inject constructor(
paramsContainer: ParamsContainer,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
) : Model(), FeeSelectorIntents {
) : Model(), FeeSelectorIntents, FeeSelectorModelCallback {
private val params = paramsContainer.require<FeeSelectorParams>()
private var appCurrency: AppCurrency = AppCurrency.Default
val feeSelectorBottomSheet = SlotNavigation<Unit>()
val uiState: StateFlow<FeeSelectorUM>
field = MutableStateFlow<FeeSelectorUM>(params.state)
@ -49,10 +52,6 @@ internal class FeeSelectorModel @Inject constructor(
uiState.value = feeSelectorUM
}
fun dismiss() {
router.pop()
}
private fun initAppCurrency() {
modelScope.launch {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
@ -102,7 +101,11 @@ internal class FeeSelectorModel @Inject constructor(
}
override fun onDoneClick() {
params.callback.onFeeResult(uiState.value)
dismiss()
(params as? FeeSelectorParams.FeeSelectorDetailsParams)?.callback?.onFeeResult(uiState.value)
}
override fun onFeeResult(feeSelectorUM: FeeSelectorUM) {
uiState.value = feeSelectorUM
feeSelectorBottomSheet.dismiss()
}
}

View file

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

View file

@ -4,10 +4,8 @@ import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
@ -17,7 +15,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
@ -52,6 +49,7 @@ import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents
import com.tangem.features.send.v2.impl.R
@ -65,6 +63,7 @@ import java.math.BigInteger
internal fun FeeSelectorModalBottomSheet(
state: FeeSelectorUM,
feeSelectorIntents: FeeSelectorIntents,
feeDisplaySource: FeeSelectorParams.FeeDisplaySource,
onDismiss: () -> Unit,
) {
if (state !is FeeSelectorUM.Content) return
@ -77,17 +76,13 @@ internal fun FeeSelectorModalBottomSheet(
),
containerColor = TangemTheme.colors.background.primary,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
startIconRes = R.drawable.ic_back_24,
onStartClick = onDismiss,
)
FeeTitle(feeDisplaySource = feeDisplaySource, onDismiss = onDismiss)
},
content = {
FeeSelectorItems(
state = state,
feeSelectorIntents = feeSelectorIntents,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp),
modifier = Modifier.padding(vertical = 4.dp, horizontal = 13.dp),
)
},
footer = {
@ -102,6 +97,26 @@ internal fun FeeSelectorModalBottomSheet(
)
}
@Composable
private fun FeeTitle(feeDisplaySource: FeeSelectorParams.FeeDisplaySource, onDismiss: () -> Unit) {
when (feeDisplaySource) {
FeeSelectorParams.FeeDisplaySource.Screen -> {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
endIconRes = R.drawable.ic_close_24,
onEndClick = onDismiss,
)
}
FeeSelectorParams.FeeDisplaySource.BottomSheet -> {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
startIconRes = R.drawable.ic_back_24,
onStartClick = onDismiss,
)
}
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun FeeSelectorItems(
@ -129,21 +144,7 @@ private fun FeeSelectorItems(
val itemModifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors.background.primary)
.then(
if (isSelected) {
Modifier
.border(
width = 2.5.dp,
color = iconTint.copy(alpha = 0.2F),
shape = RoundedCornerShape(16.dp),
)
.padding(2.5.dp)
.border(width = 1.dp, color = iconTint, shape = RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
} else {
Modifier
},
)
.selectedBorder(isSelected = isSelected)
.clickableSingle(onClick = { feeSelectorIntents.onFeeItemSelected(item) })
when (item) {
is FeeItem.Suggested -> RegularFeeItemContent(
@ -477,7 +478,12 @@ private fun FeeSelectorBS_Preview(
state: FeeSelectorUM.Content,
) {
TangemThemePreview {
FeeSelectorModalBottomSheet(onDismiss = {}, state = state, feeSelectorIntents = StubFeeSelectorIntents())
FeeSelectorModalBottomSheet(
onDismiss = {},
state = state,
feeSelectorIntents = StubFeeSelectorIntents(),
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet,
)
}
}