Updated on 2026-08-14

This commit is contained in:
Tangem 2023-07-13 09:50:38 +03:00
parent bde99e272b
commit d8348e54df
14 changed files with 448 additions and 27 deletions

View file

@ -0,0 +1,71 @@
package com.tangem.core.ui.utils
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
object BigDecimalFormatter {
private const val TEMP_CURRENCY_CODE = "USD"
fun formatCryptoAmount(
cryptoAmount: BigDecimal,
cryptoCurrency: String,
decimals: Int,
locale: Locale = Locale.getDefault(),
): String {
val formatterCurrency = getCurrency(cryptoCurrency)
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
roundingMode = RoundingMode.DOWN
}
return formatter.format(cryptoAmount)
.replace(formatterCurrency.getSymbol(locale), cryptoCurrency)
}
fun formatFiatAmount(
fiatAmount: BigDecimal,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
val formatterCurrency = getCurrency(fiatCurrencyCode)
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency
maximumFractionDigits = 2
minimumFractionDigits = 2
roundingMode = RoundingMode.HALF_UP
}
return formatter.format(fiatAmount)
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
fun formatPercent(percent: BigDecimal, useAbsoluteValue: Boolean, locale: Locale = Locale.getDefault()): String {
val formatter = NumberFormat.getPercentInstance(locale).apply {
maximumFractionDigits = 2
minimumFractionDigits = 2
roundingMode = RoundingMode.HALF_UP
}
val value = if (useAbsoluteValue) percent.abs() else percent
return formatter.format(value)
}
private fun getCurrency(code: String): Currency {
return runCatching { Currency.getInstance(code) }
.getOrElse { e ->
// Currency code is not valid ISO 4217 code
if (e is IllegalArgumentException) {
Currency.getInstance(TEMP_CURRENCY_CODE)
} else {
throw e
}
}
}
}

View file

@ -1,9 +1,7 @@
package com.tangem.domain.tokens.model
import arrow.core.NonEmptySet
data class NetworkGroup(
val networkId: Network.ID,
val name: String,
val tokens: NonEmptySet<TokenStatus>,
val tokens: Set<TokenStatus>,
)

View file

@ -1,6 +1,5 @@
package com.tangem.domain.tokens.model
import arrow.core.NonEmptySet
import java.math.BigDecimal
sealed class TokenList {
@ -8,13 +7,13 @@ sealed class TokenList {
open val sortedBy: SortType = SortType.NONE
data class GroupedByNetwork(
val groups: NonEmptySet<NetworkGroup>,
val groups: Set<NetworkGroup>,
override val totalFiatBalance: FiatBalance,
override val sortedBy: SortType,
) : TokenList()
data class Ungrouped(
val tokens: NonEmptySet<TokenStatus>,
val tokens: Set<TokenStatus>,
override val totalFiatBalance: FiatBalance,
override val sortedBy: SortType,
) : TokenList()

View file

@ -84,7 +84,7 @@ internal class TokenListOperations(
): NonEmptySet<NetworkGroup> {
val groupsWithSortedTokens = groupTokens(tokens, networks)
.map { group ->
group.copy(tokens = sortTokensByBalance(group.tokens))
group.copy(tokens = sortTokensByBalance(group.tokens as NonEmptySet<TokenStatus>))
}
.toNonEmptySet()
val sortedGroups = if (isAnyTokenLoading) {

View file

@ -68,7 +68,7 @@ internal object MockTokenLists {
val loadingUngroupedTokenList = with(ungroupedTokenList) {
copy(
tokens = tokens.map { it.copy(value = TokenStatus.Loading) }.toNonEmptySet(),
tokens = tokens.map { it.copy(value = TokenStatus.Loading) }.toSet(),
totalFiatBalance = TokenList.FiatBalance.Loading,
)
}
@ -91,7 +91,7 @@ internal object MockTokenLists {
val sortedGroupedTokenList: TokenList.GroupedByNetwork
get() {
val groups = sortedNetworksGroups
val groups = sortedNetworksGroups.toSet()
return groupedTokenList.copy(
groups = groups,

View file

@ -29,6 +29,7 @@ dependencies {
implementation(deps.kotlin.immutable.collections)
implementation(deps.tangem.card.core)
implementation(deps.tangem.blockchain)
implementation(deps.arrow.core)
/** DI */
implementation(deps.hilt.android)

View file

@ -4,6 +4,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.marketprice.PriceChangeConfig
import com.tangem.core.ui.components.transactions.TransactionState
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.state.TokenItemState.TokenOptionsState
import com.tangem.feature.wallet.presentation.organizetokens.DraggableItem
@ -51,14 +52,16 @@ internal object WalletPreviewData {
onClick = null,
)
val wallets = mapOf(
UserWalletId(stringValue = "123") to walletCardContentState,
UserWalletId(stringValue = "321") to walletCardLoadingState,
UserWalletId(stringValue = "42") to walletCardHiddenContentState,
UserWalletId(stringValue = "24") to walletCardErrorState,
)
val walletListConfig = WalletsListConfig(
selectedWalletIndex = 0,
wallets = persistentListOf(
walletCardContentState,
walletCardLoadingState,
walletCardHiddenContentState,
walletCardErrorState,
),
wallets = wallets.values.toPersistentList(),
onWalletChange = {},
)

View file

@ -0,0 +1,40 @@
package com.tangem.feature.wallet.presentation.wallet.utils
import com.tangem.core.ui.utils.BigDecimalFormatter.formatFiatAmount
import com.tangem.domain.tokens.model.TokenList
import com.tangem.feature.wallet.presentation.wallet.state.WalletCardState
import com.tangem.utils.converter.Converter
internal class FiatBalanceToWalletCardConverter(
private val currentState: WalletCardState,
private val isWalletContentHidden: Boolean,
private val fiatCurrencyCode: String,
private val fiatCurrencySymbol: String,
) : Converter<TokenList.FiatBalance, WalletCardState> {
override fun convert(value: TokenList.FiatBalance): WalletCardState {
// TODO: [REDACTED_JIRA]
return when (value) {
is TokenList.FiatBalance.Loading -> with(currentState) {
WalletCardState.Loading(id, title, additionalInfo, imageResId, onClick)
}
is TokenList.FiatBalance.Failed -> with(currentState) {
WalletCardState.Error(id, title, additionalInfo, imageResId, onClick)
}
is TokenList.FiatBalance.Loaded -> with(currentState) {
if (isWalletContentHidden) {
WalletCardState.HiddenContent(id, title, additionalInfo, imageResId, onClick)
} else {
WalletCardState.Content(
id = id,
title = title,
additionalInfo = additionalInfo,
imageResId = imageResId,
onClick = onClick,
balance = formatFiatAmount(value.amount, fiatCurrencyCode, fiatCurrencySymbol),
)
}
}
}
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.feature.wallet.presentation.wallet.utils
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.wallet.state.WalletContentItemState.MultiCurrencyItem
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal object LoadingItemsProvider {
fun getLoadingMultiCurrencyTokens(): PersistentList<MultiCurrencyItem> {
return List(size = 5) { TokenItemState.Loading }
.map { MultiCurrencyItem.Token(it) }
.toPersistentList()
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.feature.wallet.presentation.wallet.utils
import com.tangem.domain.tokens.error.TokensError
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateHolder
import com.tangem.utils.converter.Converter
internal class TokenErrorToWalletStateConverter(
private val currentState: WalletStateHolder,
) : Converter<TokensError, WalletStateHolder> {
// TODO: [REDACTED_JIRA]
override fun convert(value: TokensError): WalletStateHolder {
return currentState
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.feature.wallet.presentation.wallet.utils
import com.tangem.domain.tokens.model.NetworkGroup
import com.tangem.domain.tokens.model.TokenList
import com.tangem.domain.tokens.model.TokenStatus
import com.tangem.feature.wallet.presentation.wallet.state.WalletContentItemState.MultiCurrencyItem
import com.tangem.feature.wallet.presentation.wallet.utils.LoadingItemsProvider.getLoadingMultiCurrencyTokens
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
internal class TokenListToContentItemsConverter(
isWalletContentHidden: Boolean,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
) : Converter<TokenList, ImmutableList<MultiCurrencyItem>> {
private val tokenStatusConverter = TokenStatusToTokenItemConverter(
isWalletContentHidden,
fiatCurrencyCode,
fiatCurrencySymbol,
)
override fun convert(value: TokenList): ImmutableList<MultiCurrencyItem> {
return when (value) {
is TokenList.GroupedByNetwork -> value.mapToMultiCurrencyItems()
is TokenList.Ungrouped -> value.mapToMultiCurrencyItems()
is TokenList.NotInitialized -> getLoadingMultiCurrencyTokens()
}
}
private fun TokenList.GroupedByNetwork.mapToMultiCurrencyItems(): PersistentList<MultiCurrencyItem> {
return groups.fold(initial = persistentListOf()) { acc, group ->
acc.mutate { it.addGroup(group) }
}
}
private fun TokenList.Ungrouped.mapToMultiCurrencyItems(): PersistentList<MultiCurrencyItem> {
return tokens.fold(initial = persistentListOf()) { acc, token ->
acc.mutate { it.addToken(token) }
}
}
private fun MutableList<MultiCurrencyItem>.addGroup(group: NetworkGroup): List<MultiCurrencyItem> {
this.add(MultiCurrencyItem.NetworkGroupTitle(group.name))
group.tokens.forEach { token ->
this.addToken(token)
}
return this
}
private fun MutableList<MultiCurrencyItem>.addToken(token: TokenStatus): List<MultiCurrencyItem> {
val tokenItemState = tokenStatusConverter.convert(token)
this.add(MultiCurrencyItem.Token(tokenItemState))
return this
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.feature.wallet.presentation.wallet.utils
import com.tangem.domain.tokens.model.TokenList
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateHolder
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateHolder.MultiCurrencyContent
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateHolder.SingleCurrencyContent
import com.tangem.feature.wallet.presentation.wallet.state.WalletsListConfig
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
internal class TokenListToWalletStateConverter(
private val currentState: WalletStateHolder,
private val isWalletContentHidden: Boolean,
private val fiatCurrencyCode: String,
private val fiatCurrencySymbol: String,
) : Converter<TokenList, WalletStateHolder> {
override fun convert(value: TokenList): WalletStateHolder {
return when (currentState) {
is MultiCurrencyContent -> currentState.updateWithTokenList(value)
is SingleCurrencyContent -> currentState.updateWithTokenList(value)
}
}
private fun MultiCurrencyContent.updateWithTokenList(tokenList: TokenList): MultiCurrencyContent {
val converter = TokenListToContentItemsConverter(isWalletContentHidden, fiatCurrencyCode, fiatCurrencySymbol)
return this.copy(
walletsListConfig = updateSelectedWallet(tokenList.totalFiatBalance),
contentItems = converter.convert(tokenList),
)
}
private fun SingleCurrencyContent.updateWithTokenList(tokenList: TokenList): SingleCurrencyContent {
return this.copy(
walletsListConfig = updateSelectedWallet(tokenList.totalFiatBalance),
)
}
private fun WalletStateHolder.updateSelectedWallet(fiatBalance: TokenList.FiatBalance): WalletsListConfig {
val selectedWalletIndex = walletsListConfig.selectedWalletIndex
val selectedWalletCard = walletsListConfig.wallets[selectedWalletIndex]
val converter = FiatBalanceToWalletCardConverter(
selectedWalletCard,
isWalletContentHidden,
fiatCurrencyCode,
fiatCurrencySymbol,
)
return walletsListConfig.copy(
wallets = walletsListConfig.wallets
.toPersistentList()
.set(selectedWalletIndex, converter.convert(fiatBalance)),
)
}
}

View file

@ -0,0 +1,105 @@
package com.tangem.feature.wallet.presentation.wallet.utils
import androidx.annotation.DrawableRes
import com.tangem.core.ui.components.marketprice.PriceChangeConfig
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.tokens.model.TokenStatus
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class TokenStatusToTokenItemConverter(
private val isWalletContentHidden: Boolean,
private val fiatCurrencyCode: String,
private val fiatCurrencySymbol: String,
) : Converter<TokenStatus, TokenItemState> {
private val TokenStatus.networkIconResId: Int?
@DrawableRes get() {
// TODO: [REDACTED_JIRA]
return if (isCoin) null else R.drawable.img_eth_22
}
private val TokenStatus.tokenIconResId: Int
@DrawableRes get() {
// TODO: [REDACTED_JIRA]
return R.drawable.img_eth_22
}
override fun convert(value: TokenStatus): TokenItemState {
return when (value.value) {
is TokenStatus.Loading -> TokenItemState.Loading
is TokenStatus.Loaded,
is TokenStatus.Custom,
-> value.mapToTokenItemState()
// TODO: Add other token item states, currently not designed
is TokenStatus.MissedDerivation,
is TokenStatus.NoAccount,
is TokenStatus.Unreachable,
-> value.mapToUnreachableTokenItemState()
}
}
private fun TokenStatus.mapToTokenItemState(): TokenItemState.Content {
return TokenItemState.Content(
id = this.id.value,
name = this.name,
tokenIconUrl = this.iconUrl,
tokenIconResId = this.tokenIconResId,
networkIconResId = this.networkIconResId,
amount = getFormattedAmount(),
hasPending = value.hasTransactionsInProgress,
tokenOptions = if (isWalletContentHidden) {
TokenItemState.TokenOptionsState.Hidden(getPriceChangeConfig())
} else {
TokenItemState.TokenOptionsState.Visible(
fiatAmount = getFormattedFiatAmount(),
priceChange = getPriceChangeConfig(),
)
},
)
}
private fun TokenStatus.getFormattedAmount(): String {
val amount = value.amount ?: return UNKNOWN_AMOUNT_SIGN
return BigDecimalFormatter.formatCryptoAmount(amount, symbol, decimals)
}
private fun TokenStatus.getFormattedFiatAmount(): String {
val fiatAmount = value.fiatAmount ?: return UNKNOWN_AMOUNT_SIGN
return BigDecimalFormatter.formatFiatAmount(fiatAmount, fiatCurrencyCode, fiatCurrencySymbol)
}
private fun TokenStatus.mapToUnreachableTokenItemState() = TokenItemState.Unreachable(
id = this.id.value,
name = this.name,
tokenIconUrl = this.iconUrl,
tokenIconResId = this.tokenIconResId,
networkIconResId = this.networkIconResId,
)
private fun TokenStatus.getPriceChangeConfig(): PriceChangeConfig {
val priceChange = value.priceChange
?: return PriceChangeConfig(UNKNOWN_AMOUNT_SIGN, PriceChangeConfig.Type.DOWN)
return PriceChangeConfig(
valueInPercent = BigDecimalFormatter.formatPercent(priceChange, useAbsoluteValue = true),
type = priceChange.getPriceChangeType(),
)
}
private fun BigDecimal?.getPriceChangeType(): PriceChangeConfig.Type {
return when {
this == null -> PriceChangeConfig.Type.DOWN
this < BigDecimal.ZERO -> PriceChangeConfig.Type.DOWN
else -> PriceChangeConfig.Type.UP
}
}
private companion object {
const val UNKNOWN_AMOUNT_SIGN = ""
}
}

View file

@ -6,16 +6,26 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.Either
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.tokens.GetTokenListUseCase
import com.tangem.domain.tokens.error.TokensError
import com.tangem.domain.tokens.model.TokenList
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.common.WalletPreviewData
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateHolder
import com.tangem.feature.wallet.presentation.wallet.state.WalletTopBarConfig
import com.tangem.feature.wallet.presentation.wallet.utils.LoadingItemsProvider.getLoadingMultiCurrencyTokens
import com.tangem.feature.wallet.presentation.wallet.utils.TokenErrorToWalletStateConverter
import com.tangem.feature.wallet.presentation.wallet.utils.TokenListToWalletStateConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.properties.Delegates
@ -27,7 +37,6 @@ import kotlin.properties.Delegates
*/
@HiltViewModel
internal class WalletViewModel @Inject constructor(
@Suppress("unused") // TODO: [REDACTED_JIRA]
private val getTokenListUseCase: GetTokenListUseCase,
private val scanCardProcessor: ScanCardProcessor,
private val dispatchers: CoroutineDispatcherProvider,
@ -40,14 +49,29 @@ internal class WalletViewModel @Inject constructor(
var uiState by mutableStateOf(getInitialState())
private set
private var getTokenListJob: Job? = null
set(value) {
field?.cancel()
field = value
}
// TODO: [REDACTED_TASK_KEY] Use production data instead of WalletPreviewData
private fun getInitialState(): WalletStateHolder = WalletPreviewData.multicurrencyWalletScreenState.copy(
onBackClick = ::onBackClick,
topBarConfig = createTopBarConfig(),
walletsListConfig = WalletPreviewData.multicurrencyWalletScreenState.walletsListConfig.copy(
onWalletChange = ::selectWallet,
),
)
private fun getInitialState(): WalletStateHolder {
val state = WalletPreviewData.multicurrencyWalletScreenState.copy(
onBackClick = ::onBackClick,
topBarConfig = createTopBarConfig(),
walletsListConfig = WalletPreviewData.multicurrencyWalletScreenState.walletsListConfig.copy(
onWalletChange = ::selectWallet,
),
contentItems = getLoadingMultiCurrencyTokens(),
)
val selectedWalletIndex = state.walletsListConfig.selectedWalletIndex
val selectedWalletId = WalletPreviewData.wallets.keys.elementAt(selectedWalletIndex)
launchGetTokenListJob(selectedWalletId)
return state
}
private fun onBackClick() {
router.popBackStack()
@ -74,14 +98,45 @@ internal class WalletViewModel @Inject constructor(
Log.i("WalletViewModel", "selectWallet: $index")
uiState = if (index % 2 == 0) {
WalletPreviewData.multicurrencyWalletScreenState.copy(
uiState = when (val state = uiState) {
is WalletStateHolder.MultiCurrencyContent -> state.copy(
walletsListConfig = uiState.walletsListConfig.copy(selectedWalletIndex = index),
)
} else {
WalletPreviewData.singleWalletScreenState.copy(
is WalletStateHolder.SingleCurrencyContent -> state.copy(
walletsListConfig = uiState.walletsListConfig.copy(selectedWalletIndex = index),
)
}
val selectedWalletId = WalletPreviewData.wallets.keys.elementAt(index)
launchGetTokenListJob(selectedWalletId)
}
private fun launchGetTokenListJob(userWalletId: UserWalletId) {
getTokenListJob = getTokenListUseCase(userWalletId)
.distinctUntilChanged()
.mapLatest(::updateStateWithTokenListOrError)
.onEach { uiState = it }
.flowOn(Dispatchers.Default)
.launchIn(viewModelScope)
}
private fun updateStateWithTokenListOrError(tokenList: Either<TokensError, TokenList>): WalletStateHolder {
val updateStateWithError = { error: TokensError ->
val converter = TokenErrorToWalletStateConverter(uiState)
converter.convert(error)
}
val updateState = { list: TokenList ->
val converter = TokenListToWalletStateConverter(
uiState,
isWalletContentHidden = false, // TODO: [REDACTED_JIRA]
fiatCurrencyCode = "USD", // TODO: [REDACTED_JIRA]
fiatCurrencySymbol = "$", // TODO: [REDACTED_JIRA]
)
converter.convert(list)
}
return tokenList.fold(ifLeft = updateStateWithError, ifRight = updateState)
}
}