Updated on 2026-08-14

This commit is contained in:
Tangem 2025-06-06 12:22:13 +05:00
parent 43c6359d2f
commit 880668b575
32 changed files with 898 additions and 17 deletions

1
features/fee-selector/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,57 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.feeselector.impl"
}
dependencies {
/** Project - API */
implementation(projects.features.feeSelector.api)
/** Project - Core */
implementation(projects.common.ui)
implementation(projects.common.routing)
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.pagination)
implementation(projects.core.navigation)
/** Project - Domain */
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/** AndroidX */
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
/** Compose */
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.shimmer)
/*** Tangem SDK */
implementation(tangemDeps.blockchain)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.decompose.ext.compose)
implementation(deps.timber)
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.feeselector.impl.component
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.feeselector.api.component.FeeSelectorComponent
import com.tangem.features.feeselector.impl.model.FeeSelectorModel
import com.tangem.features.feeselector.impl.ui.FeeSelectorModalBottomSheet
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultFeeSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: FeeSelectorComponent.Params,
) : FeeSelectorComponent, AppComponentContext by appComponentContext {
private val model: FeeSelectorModel = getOrCreateModel(params = params)
override fun dismiss() {
model.dismiss()
}
@Composable
override fun BottomSheet() {
FeeSelectorModalBottomSheet(onDismiss = ::dismiss, state = TODO())
}
// Temporary workaround, to use test this component
@Composable
override fun Content(modifier: Modifier) {
BackHandler(onBack = router::pop)
FeeSelectorModalBottomSheet(onDismiss = ::dismiss, state = TODO())
}
@AssistedFactory
interface Factory : FeeSelectorComponent.Factory {
override fun create(
context: AppComponentContext,
params: FeeSelectorComponent.Params,
): DefaultFeeSelectorComponent
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.feeselector.impl.di
import com.tangem.features.feeselector.api.component.FeeSelectorComponent
import com.tangem.features.feeselector.impl.component.DefaultFeeSelectorComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface FeeSelectorFeatureModule {
@Binds
@Singleton
fun bindComponentFactory(factory: DefaultFeeSelectorComponent.Factory): FeeSelectorComponent.Factory
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.feeselector.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.feeselector.impl.model.FeeSelectorModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface FeeSelectorModelModule {
@Binds
@IntoMap
@ClassKey(FeeSelectorModel::class)
fun bindModel(model: FeeSelectorModel): Model
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.feeselector.impl.entity
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.Amount
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
@Immutable
internal sealed class FeeSelectorUM {
data object Loading : FeeSelectorUM()
data class Content(
val isDoneEnabled: Boolean,
val feeItems: ImmutableList<FeeItem>,
val selectedFeeItem: FeeItem,
val isFeeApproximate: Boolean,
val feeFiatRateDataHolder: FeeFiatRateDataHolder?,
) : FeeSelectorUM()
}
@Immutable
data class FeeFiatRateDataHolder(
val rate: BigDecimal,
val appCurrency: AppCurrency,
)
@Immutable
internal sealed class FeeItem {
abstract val onSelect: () -> Unit
data class Suggested(val title: TextReference, val amount: Amount, override val onSelect: () -> Unit) : FeeItem()
data class Slow(val amount: Amount, override val onSelect: () -> Unit) : FeeItem()
data class Market(val amount: Amount, override val onSelect: () -> Unit) : FeeItem()
data class Fast(val amount: Amount, override val onSelect: () -> Unit) : FeeItem()
data class Custom(val customValues: ImmutableList<CustomFeeFieldUM>, override val onSelect: () -> Unit) : FeeItem()
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.feeselector.impl.model
import androidx.compose.runtime.Stable
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.features.feeselector.api.component.FeeSelectorComponent
import com.tangem.features.feeselector.impl.entity.FeeSelectorUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@Stable
@ModelScoped
internal class FeeSelectorModel @Inject constructor(
paramsContainer: ParamsContainer,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
@Suppress("UnusedPrivateMember")
private val params = paramsContainer.require<FeeSelectorComponent.Params>()
val uiState: StateFlow<FeeSelectorUM>
field = MutableStateFlow<FeeSelectorUM>(FeeSelectorUM.Loading)
fun dismiss() {
router.pop()
}
}

View file

@ -0,0 +1,510 @@
package com.tangem.features.feeselector.impl.ui
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
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
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.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.common.ui.amountScreen.utils.getFiatReference
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.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.domain.appcurrency.model.AppCurrency
import com.tangem.features.feeselector.api.entity.CustomFeeFieldUM
import com.tangem.features.feeselector.impl.R
import com.tangem.features.feeselector.impl.entity.FeeFiatRateDataHolder
import com.tangem.features.feeselector.impl.entity.FeeItem
import com.tangem.features.feeselector.impl.entity.FeeSelectorUM
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Composable
internal fun FeeSelectorModalBottomSheet(state: FeeSelectorUM.Content, onDismiss: () -> Unit) {
TangemModalBottomSheetWithFooter<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors.background.primary,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
startIconRes = R.drawable.ic_back_24,
onStartClick = onDismiss,
)
},
content = {
FeeSelectorItems(state = state, modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp))
},
footer = {
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
text = stringResourceSafe(R.string.common_done),
onClick = onDismiss,
enabled = state.isDoneEnabled,
)
},
)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun FeeSelectorItems(state: FeeSelectorUM.Content, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
state.feeItems.fastForEachIndexed { index, item ->
val isSelected = item == state.selectedFeeItem
val lastItem = index == state.feeItems.size - 1
val iconTint by animateColorAsState(
targetValue = if (isSelected) TangemTheme.colors.icon.accent else TangemTheme.colors.text.tertiary,
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 onSelect by rememberUpdatedState(item.onSelect)
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
},
)
.clickableSingle(onClick = onSelect)
when (item) {
is FeeItem.Suggested -> RegularFeeItemContent(
modifier = itemModifier,
title = item.title,
iconRes = R.drawable.ic_star_mini_24,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
preDot = stringReference(
item.amount.value.format {
crypto(
symbol = item.amount.currencySymbol,
decimals = item.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
},
),
postDot = if (state.feeFiatRateDataHolder != null) {
getFiatReference(
value = item.amount.value,
rate = state.feeFiatRateDataHolder.rate,
appCurrency = state.feeFiatRateDataHolder.appCurrency,
)
} else {
null
},
ellipsizeOffset = item.amount.currencySymbol.length,
showDivider = !isSelected && !lastItem,
)
is FeeItem.Slow -> RegularFeeItemContent(
modifier = itemModifier,
title = resourceReference(R.string.common_fee_selector_option_slow),
iconRes = R.drawable.ic_tortoise_24,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
preDot = stringReference(
item.amount.value.format {
crypto(
symbol = item.amount.currencySymbol,
decimals = item.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
},
),
postDot = if (state.feeFiatRateDataHolder != null) {
getFiatReference(
value = item.amount.value,
rate = state.feeFiatRateDataHolder.rate,
appCurrency = state.feeFiatRateDataHolder.appCurrency,
)
} else {
null
},
ellipsizeOffset = item.amount.currencySymbol.length,
showDivider = !isSelected && !lastItem,
)
is FeeItem.Market -> RegularFeeItemContent(
modifier = itemModifier,
title = resourceReference(R.string.common_fee_selector_option_market),
iconRes = R.drawable.ic_bird_24,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
preDot = stringReference(
item.amount.value.format {
crypto(
symbol = item.amount.currencySymbol,
decimals = item.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
},
),
postDot = if (state.feeFiatRateDataHolder != null) {
getFiatReference(
value = item.amount.value,
rate = state.feeFiatRateDataHolder.rate,
appCurrency = state.feeFiatRateDataHolder.appCurrency,
)
} else {
null
},
ellipsizeOffset = item.amount.currencySymbol.length,
showDivider = !isSelected && !lastItem,
)
is FeeItem.Fast -> RegularFeeItemContent(
modifier = itemModifier,
title = resourceReference(R.string.common_fee_selector_option_fast),
iconRes = R.drawable.ic_hare_24,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
preDot = stringReference(
item.amount.value.format {
crypto(
symbol = item.amount.currencySymbol,
decimals = item.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
},
),
postDot = if (state.feeFiatRateDataHolder != null) {
getFiatReference(
value = item.amount.value,
rate = state.feeFiatRateDataHolder.rate,
appCurrency = state.feeFiatRateDataHolder.appCurrency,
)
} else {
null
},
ellipsizeOffset = item.amount.currencySymbol.length,
showDivider = !isSelected && !lastItem,
)
is FeeItem.Custom -> CustomFeeBlock(
modifier = itemModifier,
customFee = item,
isSelected = isSelected,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
)
}
}
}
}
@Composable
private fun CustomFeeBlock(
customFee: FeeItem.Custom,
isSelected: Boolean,
iconBackgroundColor: Color,
iconTint: Color,
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 = { _, _ -> })
}
}
}
@Composable
private fun ExpandedCustomFeeItems(
customFeeFields: ImmutableList<CustomFeeFieldUM>,
onValueChange: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
customFeeFields.fastForEachIndexed { index, field ->
val showDivider = index != customFeeFields.size - 1
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,
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
onValueChange = { onValueChange(index, it) },
showDivider = showDivider,
isReadOnly = field.isReadonly,
)
} else {
InputRowEnterInfoAmountV2(
text = field.value,
decimals = field.decimals,
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
symbol = field.symbol,
onValueChange = { onValueChange(index, it) },
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
showDivider = showDivider,
)
}
}
}
}
@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)
}
}
private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider<FeeSelectorUM.Content>(
collection = listOf(
FeeSelectorUM.Content(
isDoneEnabled = false,
feeItems = persistentListOf(
FeeItem.Suggested(
title = stringReference("Suggested by Tangem"),
amount = Amount(value = BigDecimal("0.1"), blockchain = Blockchain.Ethereum),
onSelect = onSelectStub,
),
FeeItem.Slow(
amount = Amount(value = BigDecimal("0.01"), blockchain = Blockchain.Ethereum),
onSelect = onSelectStub,
),
FeeItem.Market(
amount = Amount(value = BigDecimal("0.02"), blockchain = Blockchain.Ethereum),
onSelect = onSelectStub,
),
FeeItem.Fast(
amount = Amount(value = BigDecimal("0.3"), blockchain = Blockchain.Ethereum),
onSelect = onSelectStub,
),
FeeItem.Custom(customValues = customFeeFields, onSelect = onSelectStub),
),
// selectedFeeItem = FeeItem.Market(
// amount = Amount(value = BigDecimal("0.02"), blockchain = Blockchain.Ethereum),
// onSelect = onSelectStub
// ),
selectedFeeItem = FeeItem.Custom(customValues = customFeeFields, onSelect = onSelectStub),
isFeeApproximate = true,
feeFiatRateDataHolder = FeeFiatRateDataHolder(
rate = BigDecimal.TEN,
appCurrency = AppCurrency.Default,
),
),
),
)
private val onSelectStub: () -> Unit = {}
private val customFeeFields = 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,
),
)