Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-27 10:06:13 +04:00
parent 27347a13e8
commit 6d3eb6de79
11 changed files with 332 additions and 67 deletions

View file

@ -74,6 +74,7 @@ data class TangemDimens internal constructor(
val size56: Dp = 56.dp,
val size60: Dp = 60.dp,
val size62: Dp = 62.dp,
val size63: Dp = 63.dp,
val size64: Dp = 64.dp,
val size68: Dp = 68.dp,
val size70: Dp = 70.dp,

View file

@ -17,11 +17,16 @@ dependencies {
implementation(projects.core.navigation)
/* Domain */
implementation(projects.domain.markets)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.markets)
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/* Compose */
implementation(deps.compose.coil)
@ -52,4 +57,7 @@ dependencies {
implementation(projects.common.ui)
implementation(projects.common.uiCharts)
implementation(projects.common.routing)
/* Libs */
implementation(projects.libs.crypto)
}

View file

@ -2,7 +2,9 @@ package com.tangem.features.markets.details.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.charts.state.*
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -22,13 +24,11 @@ import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter
import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter
import com.tangem.features.markets.details.impl.model.formatter.*
import com.tangem.features.markets.details.impl.model.formatter.formatAsPrice
import com.tangem.features.markets.details.impl.model.formatter.getChangePercentBetween
import com.tangem.features.markets.details.impl.model.formatter.getPercentByInterval
import com.tangem.features.markets.details.impl.model.state.TokenNetworksState
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
@ -296,7 +296,9 @@ internal class MarketsTokenDetailsModel @Inject constructor(
)
}
val networks = result.networks
val networks = result.networks?.filter {
BlockchainUtils.isSupportedNetworkId(it.networkId)
}
networksState.value = if (networks.isNullOrEmpty()) {
TokenNetworksState.NoNetworksAvailable

View file

@ -2,14 +2,15 @@ package com.tangem.features.markets.portfolio.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.domain.markets.TokenMarketInfo
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioModel
import com.tangem.features.markets.portfolio.impl.ui.MyPortfolio
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -27,10 +28,9 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
MyPortfolio(
modifier = modifier,
state = MyPortfolioUM.Loading,
)
val state by model.state.collectAsStateWithLifecycle()
MyPortfolio(modifier = modifier, state = state)
}
@AssistedFactory

View file

@ -1,30 +1,92 @@
package com.tangem.features.markets.portfolio.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.tokens.GetAllWalletsCryptoCurrencyStatusesUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
@Stable
@ComponentScoped
internal class MarketsPortfolioModel @Inject constructor(
paramsContainer: ParamsContainer,
getAllWalletsCryptoCurrencyStatusesUseCase: GetAllWalletsCryptoCurrencyStatusesUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
@Suppress("UnusedPrivateMember")
val state: StateFlow<MyPortfolioUM> get() = _state
private val _state: MutableStateFlow<MyPortfolioUM> = MutableStateFlow(value = MyPortfolioUM.Loading)
private val params = paramsContainer.require<MarketsPortfolioComponent.Params>()
@Suppress("UnusedPrivateMember")
private val availableNetworks = MutableStateFlow<List<TokenMarketInfo.Network>?>(value = null)
private val factory = MyPortfolioUMMFactory(::onAddClick, ::onTokenItemClick)
init {
combine(
flow = getAllWalletsCryptoCurrencyStatusesUseCase(params.tokenId),
flow2 = availableNetworks,
flow3 = getSelectedAppCurrencyUseCase(),
flow4 = getBalanceHidingSettingsUseCase(),
) { walletsWithCurrencyStatuses, networks, maybeAppCurrency, isBalanceHidden ->
val appCurrency = maybeAppCurrency.getOrElse { e ->
Timber.e("Failed to load app currency: $e")
AppCurrency.Default
}
factory.create(
walletsWithCurrencyStatuses = walletsWithCurrencyStatuses,
availableNetworks = networks,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden.isBalanceHidden,
)
}
.onEach { _state.value = it }
.launchIn(modelScope)
}
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
// TODO [REDACTED_TASK_KEY]
availableNetworks.value = networks
}
fun setNoNetworksAvailable() {
// TODO [REDACTED_TASK_KEY]
availableNetworks.value = emptyList()
}
private fun onAddClick() {
// TODO [REDACTED_JIRA]
}
private fun onTokenItemClick(token: CryptoCurrencyStatus) {
_state.update {
(_state.value as? MyPortfolioUM.Tokens)?.let { state ->
state.copy(
tokens = state.tokens.map { tokenUM ->
if (tokenUM.tokenItemState.id == token.currency.id.value) {
tokenUM.copy(isQuickActionsShown = !tokenUM.isQuickActionsShown)
} else {
tokenUM
}
}
.toImmutableList(),
)
} ?: it
}
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.markets.portfolio.impl.model
import arrow.core.Either
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
/**
* Factory for creating [MyPortfolioUM]
*
* @property onAddClick callback when user wants to add new token
* @property onTokenItemClick callback when user wants to see actions with token
*
[REDACTED_AUTHOR]
*/
internal class MyPortfolioUMMFactory(
private val onAddClick: () -> Unit,
private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit,
) {
/**
* Create [MyPortfolioUM] from data set
*
* @param walletsWithCurrencyStatuses wallets with currency statuses
* @param availableNetworks available networks
* @param appCurrency app currency
* @param isBalanceHidden flag that indicates if balance should be hidden
*/
fun create(
walletsWithCurrencyStatuses: Map<UserWallet, List<Either<CurrencyStatusError, CryptoCurrencyStatus>>>,
availableNetworks: List<TokenMarketInfo.Network>?,
appCurrency: AppCurrency,
isBalanceHidden: Boolean,
): MyPortfolioUM {
if (availableNetworks == null) return MyPortfolioUM.Loading
if (availableNetworks.isEmpty()) return MyPortfolioUM.Unavailable
val availableWalletsWithStatuses = walletsWithCurrencyStatuses
.filterAvailable(networks = availableNetworks)
.flatMapStatuses()
if (availableWalletsWithStatuses.isEmpty()) {
return MyPortfolioUM.AddFirstToken(onAddClick = onAddClick)
}
return TokensPortfolioUMConverter(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
availableNetworks = availableNetworks,
onAddClick = onAddClick,
onTokenItemClick = onTokenItemClick,
)
.convert(availableWalletsWithStatuses)
}
private fun Map<UserWallet, List<Either<CurrencyStatusError, CryptoCurrencyStatus>>>.filterAvailable(
networks: List<TokenMarketInfo.Network>,
): Map<UserWallet, List<CryptoCurrencyStatus>> {
val networkIds = networks.map { it.networkId }
return mapValues { entry ->
entry.value.mapNotNull { maybeStatus ->
maybeStatus.getOrNull()
?.takeIf { networkIds.contains(it.currency.network.backendId) }
}
}
}
private fun Map<UserWallet, List<CryptoCurrencyStatus>>.flatMapStatuses(): Map<UserWallet, CryptoCurrencyStatus> {
return this
.flatMap { entry ->
entry.value.map { entry.key to it }
}
.toMap()
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.utils.converter.Converter
/**
* Converter from [UserWallet] and [CryptoCurrencyStatus] to [PortfolioTokenUM]
*
[REDACTED_AUTHOR]
*/
internal class PortfolioTokenUMConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit,
) : Converter<Map.Entry<UserWallet, CryptoCurrencyStatus>, PortfolioTokenUM> {
override fun convert(value: Map.Entry<UserWallet, CryptoCurrencyStatus>): PortfolioTokenUM {
val (userWallet, cryptoCurrencyStatus) = value
val tokenItemStateConverter = TokenItemStateConverter(
appCurrency = appCurrency,
titleStateProvider = { TokenItemState.TitleState.Content(text = userWallet.name) },
subtitleStateProvider = {
TokenItemState.SubtitleState.TextContent(value = cryptoCurrencyStatus.currency.name)
},
onItemClick = onTokenItemClick,
onItemLongClick = null,
)
return PortfolioTokenUM(
tokenItemState = tokenItemStateConverter.convert(value = cryptoCurrencyStatus),
isBalanceHidden = isBalanceHidden,
isQuickActionsShown = false,
onQuickActionClick = {
// TODO: [REDACTED_JIRA]
},
)
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
/**
* Converter from [Map] of [UserWallet] and [CryptoCurrencyStatus] to [MyPortfolioUM.Tokens]
*
[REDACTED_AUTHOR]
*/
internal class TokensPortfolioUMConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val availableNetworks: List<TokenMarketInfo.Network>,
private val onAddClick: () -> Unit,
private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit,
) : Converter<Map<UserWallet, CryptoCurrencyStatus>, MyPortfolioUM.Tokens> {
override fun convert(value: Map<UserWallet, CryptoCurrencyStatus>): MyPortfolioUM.Tokens {
return MyPortfolioUM.Tokens(
tokens = PortfolioTokenUMConverter(appCurrency, isBalanceHidden, onTokenItemClick)
.convertList(value.entries)
.toImmutableList(),
buttonState = getAddButtonState(
availableWalletsWithStatuses = value,
availableNetworks = availableNetworks,
),
onAddClick = onAddClick,
)
}
private fun getAddButtonState(
availableWalletsWithStatuses: Map<UserWallet, CryptoCurrencyStatus>,
availableNetworks: List<TokenMarketInfo.Network>,
): AddButtonState {
val networkIds = availableNetworks.map { it.networkId }
val allNetworksAdded = availableWalletsWithStatuses
// User can add currencies only in multi-currency wallets
.filterKeys(UserWallet::isMultiCurrency)
.entries
.groupBy(
keySelector = { it.key },
valueTransform = { it.value.currency.network.backendId },
)
.mapValues { it.value.toSet() }
// Each wallets contains all available networks?
.all { it.value.containsAll(networkIds) }
return if (allNetworksAdded) AddButtonState.Unavailable else AddButtonState.Available
}
}

View file

@ -11,7 +11,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SmallButtonShimmer
@ -26,51 +25,68 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState
@Composable
internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
contentHorizontalPadding = 0.dp,
title = {
Text(
text = stringResource(R.string.markets_common_my_portfolio),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
contentHorizontalPadding = TangemTheme.dimens.spacing0,
title = { Title() },
action = {
if (state !is MyPortfolioUM.Tokens) return@InformationBlock
when (state.buttonState) {
MyPortfolioUM.Tokens.AddButtonState.Loading -> {
SmallButtonShimmer(
modifier = Modifier.size(width = 63.dp, height = TangemTheme.dimens.size18),
shape = RoundedCornerShape(TangemTheme.dimens.radius3),
)
}
else -> {
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.markets_add_token),
icon = TangemButtonIconPosition.Start(R.drawable.ic_plus_24),
onClick = state.onAddClick,
enabled = state.buttonState == MyPortfolioUM.Tokens.AddButtonState.Available,
),
)
}
}
AddButton(state = state.buttonState, onClick = state.onAddClick)
},
) {
val contentModifier = Modifier.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
)
when (state) {
is MyPortfolioUM.Tokens -> TokenList(state = state)
is MyPortfolioUM.AddFirstToken -> AddFirstTokenContent(state = state)
MyPortfolioUM.Loading -> LoadingPlaceholder()
MyPortfolioUM.Unavailable -> UnavailableContent()
is MyPortfolioUM.AddFirstToken -> AddFirstTokenContent(state = state, modifier = contentModifier)
MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier)
MyPortfolioUM.Unavailable -> UnavailableContent(modifier = contentModifier)
}
}
AddToPortfolioBottomSheet(state.addToPortfolioBS)
AddToPortfolioBottomSheet(config = state.addToPortfolioBS)
}
@Composable
private fun Title() {
Text(
text = stringResource(R.string.markets_common_my_portfolio),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun AddButton(state: AddButtonState, onClick: () -> Unit) {
when (state) {
AddButtonState.Loading -> {
SmallButtonShimmer(
modifier = Modifier.size(width = TangemTheme.dimens.size63, height = TangemTheme.dimens.size18),
shape = RoundedCornerShape(TangemTheme.dimens.radius3),
)
}
AddButtonState.Available,
AddButtonState.Unavailable,
-> {
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.markets_add_token),
icon = TangemButtonIconPosition.Start(R.drawable.ic_plus_24),
onClick = onClick,
enabled = state == AddButtonState.Available,
),
)
}
}
}
@Composable
@ -90,12 +106,7 @@ private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier
@Composable
private fun UnavailableContent(modifier: Modifier = Modifier) {
Text(
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
modifier = modifier,
text = stringResource(R.string.markets_add_to_my_portfolio_unavailable_description),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
@ -105,12 +116,7 @@ private fun UnavailableContent(modifier: Modifier = Modifier) {
@Composable
private fun AddFirstTokenContent(state: MyPortfolioUM.AddFirstToken, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Text(
@ -118,6 +124,7 @@ private fun AddFirstTokenContent(state: MyPortfolioUM.AddFirstToken, modifier: M
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.markets_add_to_portfolio_button),
@ -128,19 +135,13 @@ private fun AddFirstTokenContent(state: MyPortfolioUM.AddFirstToken, modifier: M
@Composable
private fun LoadingPlaceholder(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
) {
Column(modifier = modifier) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.7f),
style = TangemTheme.typography.body2,

View file

@ -20,4 +20,7 @@ dependencies {
/** Core */
implementation(projects.core.utils)
/** Libs */
implementation(projects.libs.blockchainSdk)
}

View file

@ -2,6 +2,8 @@ package com.tangem.lib.crypto
import com.tangem.blockchain.blockchains.xrp.XrpAddressService
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.isSupportedInApp
import com.tangem.lib.crypto.converter.XrpTaggedAddressConverter
import com.tangem.lib.crypto.models.XrpTaggedAddress
@ -55,4 +57,8 @@ object BlockchainUtils {
val blockchain = Blockchain.fromId(networkId)
return blockchain == Blockchain.Binance || blockchain == Blockchain.BinanceTestnet
}
fun isSupportedNetworkId(networkId: String): Boolean {
return Blockchain.fromNetworkId(networkId)?.isSupportedInApp() ?: false
}
}