Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-25 16:31:29 +04:00
parent aeef2b3945
commit ff5df41d6a
124 changed files with 36 additions and 11457 deletions

View file

@ -1,6 +1,5 @@
package com.tangem.features.feed.entry.featuretoggle
interface FeedFeatureToggle {
val isFeedEnabled: Boolean
val isEarnBlockEnabled: Boolean
}

View file

@ -7,9 +7,6 @@ internal class DefaultFeedFeatureToggle(
private val featureTogglesManager: FeatureTogglesManager,
) : FeedFeatureToggle {
override val isFeedEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("FEED_ENABLED")
override val isEarnBlockEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("EARN_BLOCK_ENABLED")
}

View file

@ -1,40 +0,0 @@
package com.tangem.features.markets.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import kotlinx.serialization.Serializable
@Stable
interface MarketsTokenDetailsComponent : ComposableContentComponent {
@Serializable
data class Params(
val token: TokenMarketParams,
val appCurrency: AppCurrency,
val shouldShowPortfolio: Boolean,
val analyticsParams: AnalyticsParams?,
)
@Serializable
data class AnalyticsParams(
val blockchain: String?,
val source: String,
)
@Composable
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
)
interface Factory : ComponentFactory<Params, MarketsTokenDetailsComponent>
}

View file

@ -1,24 +0,0 @@
package com.tangem.features.markets.entry
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
@Stable
interface MarketsEntryComponent {
@Composable
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
)
interface Factory {
fun create(context: AppComponentContext): MarketsEntryComponent
}
}

View file

@ -1,164 +0,0 @@
package com.tangem.features.markets.details.impl
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.MarketsTokenDetailsComponent.Params
import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel
import com.tangem.features.markets.details.impl.model.state.TokenNetworksState
import com.tangem.features.markets.details.impl.ui.MarketsTokenDetailsContent
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@Stable
internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Params,
analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
portfolioComponentFactory: MarketsPortfolioComponent.Factory,
) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent {
// applying l2 compatibility
private val updatedParams = params.copy(
token = params.token.copy(
id = CryptoCurrency.RawID(getTokenIdIfL2Network(params.token.id.value)),
),
)
private val analyticsParams = params.analyticsParams
private val model: MarketsTokenDetailsModel = getOrCreateModel(updatedParams)
private val portfolioComponent: MarketsPortfolioComponent? = if (updatedParams.shouldShowPortfolio) {
portfolioComponentFactory.create(
context = child("my_portfolio"),
params = MarketsPortfolioComponent.Params(
updatedParams.token,
analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) },
),
)
} else {
null
}
init {
componentScope.launch {
model.networksState.collectLatest { networksState ->
when (networksState) {
is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(
networksState.networks,
)
TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable()
else -> {}
}
}
}
// === Analytics ===
if (analyticsParams != null) {
analyticsEventHandler.send(
MarketDetailsAnalyticsEvent.EventBuilder(
token = params.token,
).screenOpened(
blockchain = analyticsParams.blockchain,
source = analyticsParams.source,
),
)
}
}
@Composable
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
val bsState by bottomSheetState
LaunchedEffect(bsState) {
model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED
}
BackHandler(enabled = bsState == BottomSheetState.EXPANDED) {
navigateBack()
}
MarketsTokenDetailsContent(
modifier = modifier,
backgroundColor = LocalMainBottomSheetColor.current.value,
addTopBarStatusBarPadding = false,
state = state,
onBackClick = ::navigateBack,
backButtonEnabled = bsState == BottomSheetState.EXPANDED,
onHeaderSizeChange = onHeaderSizeChange,
isAccountEnabled = accountsFeatureToggles.isFeatureEnabled,
portfolioBlock = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)
}
},
)
}
@Composable
override fun Content(modifier: Modifier) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
MarketsTokenDetailsContent(
modifier = modifier,
backgroundColor = TangemTheme.colors.background.tertiary,
addTopBarStatusBarPadding = true,
state = state,
onBackClick = ::navigateBack,
backButtonEnabled = true,
onHeaderSizeChange = {},
isAccountEnabled = accountsFeatureToggles.isFeatureEnabled,
portfolioBlock = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)
}
},
)
}
private fun navigateBack() = router.pop()
@AssistedFactory
interface Factory : MarketsTokenDetailsComponent.Factory {
override fun create(context: AppComponentContext, params: Params): DefaultMarketsTokenDetailsComponent
}
}

View file

@ -1,84 +0,0 @@
package com.tangem.features.markets.details.impl.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketParams
internal class MarketDetailsAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) {
data class EventBuilder(
val token: TokenMarketParams,
) {
fun screenOpened(blockchain: String?, source: String) = MarketDetailsAnalyticsEvent(
event = "Token Chart Screen Opened",
params = buildMap {
put("Token", token.symbol)
blockchain?.let { put("blockchain", it) }
put("Source", source)
},
)
fun intervalChanged(intervalType: IntervalType, interval: PriceChangeInterval) = MarketDetailsAnalyticsEvent(
event = "Button - Period",
params = mapOf(
"Token" to token.symbol,
"Period" to interval.toAnalyticsString(),
"Source" to intervalType.source,
),
)
fun readMoreClicked() = MarketDetailsAnalyticsEvent(
event = "Button - Read More",
params = mapOf(
"Token" to token.symbol,
),
)
fun linkClicked(linkTitle: String) = MarketDetailsAnalyticsEvent(
event = "Button - Links",
params = mapOf(
"Token" to token.symbol,
"Link" to linkTitle,
),
)
fun exchangesScreenOpened() = MarketDetailsAnalyticsEvent(
event = "Exchanges Screen Opened",
params = mapOf(
"Token" to token.symbol,
),
)
fun securityScoreOpened() = MarketDetailsAnalyticsEvent(
event = "Security Score Info",
params = mapOf("Token" to token.symbol),
)
fun securityScoreProviderClicked(provider: String) = MarketDetailsAnalyticsEvent(
event = "Security Score Provider Clicked",
params = mapOf(
"Token" to token.symbol,
"Provider" to provider,
),
)
}
enum class IntervalType(val source: String) {
Chart("Chart"),
PricePerformance("Price"),
Insights("Insights"),
}
}
private fun PriceChangeInterval.toAnalyticsString() = when (this) {
PriceChangeInterval.H24 -> "24h"
PriceChangeInterval.WEEK -> "7d"
PriceChangeInterval.MONTH -> "1m"
PriceChangeInterval.MONTH3 -> "3m"
PriceChangeInterval.MONTH6 -> "6m"
PriceChangeInterval.YEAR -> "1y"
PriceChangeInterval.ALL_TIME -> "All"
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.markets.details.impl.di
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.DefaultMarketsTokenDetailsComponent
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 ComponentModule {
@Binds
@Singleton
fun bindMarketsTokenDetailsComponent(
factory: DefaultMarketsTokenDetailsComponent.Factory,
): MarketsTokenDetailsComponent.Factory
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.markets.details.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(MarketsTokenDetailsModel::class)
fun provideMarketsTokenDetailsModel(model: MarketsTokenDetailsModel): Model
}

View file

@ -1,645 +0,0 @@
package com.tangem.features.markets.details.impl.model
import androidx.compose.runtime.Stable
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.sorted
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.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.markets.*
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter
import com.tangem.features.markets.details.impl.model.converters.ExchangeItemStateConverter
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.state.QuotesStateUpdater
import com.tangem.features.markets.details.impl.model.state.TokenNetworksState
import com.tangem.features.markets.details.impl.ui.state.ExchangesBottomSheetContent
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
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.joda.time.DateTime
import java.math.BigDecimal
import java.util.Locale
import javax.inject.Inject
@Suppress("LargeClass", "LongParameterList")
@Stable
@ModelScoped
internal class MarketsTokenDetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase,
private val getTokenExchangesUseCase: GetTokenExchangesUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserCountryUseCase: GetUserCountryUseCase,
private val getUserWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val quotesJob = JobHolder()
private var userCountry: UserCountry? = null
private val params = paramsContainer.require<MarketsTokenDetailsComponent.Params>()
private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token)
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = params.appCurrency,
)
private val infoConverter = TokenMarketInfoConverter(
appCurrency = Provider { currentAppCurrency.value },
onInfoClick = { showBottomSheet(it) },
onListedOnClick = ::onListedOnClick,
onLinkClick = { link ->
urlOpener.openUrl(link.url)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.linkClicked(linkTitle = link.title))
},
onSecurityScoreInfoClick = { content ->
showBottomSheet(content)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.securityScoreOpened())
},
onSecurityScoreProviderLinkClick = { provider ->
provider.urlData?.fullUrl?.let { url ->
urlOpener.openUrl(url)
}
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(provider.name))
},
// === Analytics ===
onPricePerformanceIntervalChanged = { interval ->
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance,
interval = interval,
),
)
},
onInsightsIntervalChanged = { interval ->
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights,
interval = interval,
),
)
},
needApplyFCARestrictions = Provider {
userCountry.needApplyFCARestrictions()
},
// ==================
)
private val descriptionConverter = DescriptionConverter(
onReadModeClicked = { content ->
showBottomSheet(content)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked())
},
needApplyFCARestrictions = Provider {
userCountry.needApplyFCARestrictions()
},
onGeneratedAINotificationClick = {
modelScope.launch {
sendFeedbackEmailUseCase(
type = FeedbackEmailType.CurrencyDescriptionError(
currencyId = params.token.id.value,
currencyName = params.token.name,
),
)
}
},
)
private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) {
chartData = MarketChartData.NoData.Loading
updateLook { currentLook ->
val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType()
currentLook.copy(
type = percentChangeType.toChartType(),
xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24),
yAxisFormatter = { value ->
value.format {
fiat(
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
}
},
)
}
}
private val currentQuotes = MutableStateFlow(
TokenQuotes(
currentPrice = params.token.tokenQuotes.currentPrice,
h24ChangePercent = params.token.tokenQuotes.h24Percent,
weekChangePercent = params.token.tokenQuotes.weekPercent,
monthChangePercent = params.token.tokenQuotes.monthPercent,
m3ChangePercent = null,
m6ChangePercent = null,
yearChangePercent = null,
allTimeChangePercent = null,
),
)
private val currentTokenInfo = MutableStateFlow<TokenMarketInfo?>(null)
private val lastUpdatedTimestamp = MutableStateFlow(DateTime.now().millis)
val isVisibleOnScreen = MutableStateFlow(false)
val networksState = MutableStateFlow<TokenNetworksState>(TokenNetworksState.Loading)
val state = MutableStateFlow(
MarketsTokenDetailsUM(
tokenName = params.token.name,
priceText = params.token.tokenQuotes.currentPrice.format {
fiat(
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
},
dateTimeText = resourceReference(R.string.common_today),
priceChangePercentText = params.token.tokenQuotes.h24Percent?.format { percent() },
priceChangeType = params.token.tokenQuotes.h24Percent.percentChangeType(),
iconUrl = params.token.imageUrl,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = chartDataProducer,
onLoadRetryClick = ::onLoadRetryClicked,
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = ::onMarkerPointSelected,
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = ::onSelectedIntervalChange,
isMarkerSet = false,
body = MarketsTokenDetailsUM.Body.Loading,
triggerPriceChange = consumedEvent(),
bottomSheetConfig = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
),
)
private val quotesStateUpdater = QuotesStateUpdater(
currentAppCurrency = Provider { currentAppCurrency.value },
state = state,
currentQuotes = currentQuotes,
lastUpdatedTimestamp = lastUpdatedTimestamp,
currentTokenInfo = currentTokenInfo,
onPricePerformanceIntervalChanged = { interval ->
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance,
interval = interval,
),
)
},
)
private val loadChartJobHolder = JobHolder()
init {
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
// reload screen if currency changed
modelScope.launch {
currentAppCurrency
.filter { it != params.appCurrency }
.collectLatest { _ ->
initialLoad()
}
}
initialLoad()
}
private fun initialLoad() {
loadInfo()
loadChart(state.value.selectedInterval)
modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS)
}
private fun loadQuotes() {
modelScope.launch {
val result = getTokenFullQuotesUseCase(
tokenId = params.token.id,
appCurrency = currentAppCurrency.value,
tokenSymbol = params.token.symbol,
)
result.onRight { res ->
updateQuotes(res)
}
}
}
private fun loadChart(interval: PriceChangeInterval) {
modelScope.launch {
state.update { currentState ->
currentState.copy(
chartState = currentState.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
),
)
}
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.NoData.Loading
}
val chart = getTokenPriceChartUseCase.invoke(
appCurrency = currentAppCurrency.value,
interval = interval,
tokenId = params.token.id,
tokenSymbol = params.token.symbol,
preview = false,
)
state.update { currentState ->
currentState.copy(
selectedInterval = interval,
chartState = currentState.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
),
)
}
chart
.onRight { updateTokenChart(it) }
.onLeft {
state.update { currentState ->
currentState.copy(
chartState = currentState.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.ERROR,
),
body = if (currentState.body is MarketsTokenDetailsUM.Body.Error) {
MarketsTokenDetailsUM.Body.Nothing
} else {
currentState.body
},
)
}
}
}.saveIn(loadChartJobHolder)
}
private suspend fun updateTokenChart(tokenChart: TokenChart) {
val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval)
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
y = tokenChart.priceY.toImmutableList(),
x = tokenChart.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted()
updateLook { currentLook ->
currentLook.copy(
xAxisFormatter = xAxisFormatter,
type = state.value.priceChangeType.toChartType(),
)
}
}
state.update { currentState ->
currentState.copy(
chartState = currentState.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.DATA,
),
body = if (currentState.body is MarketsTokenDetailsUM.Body.Nothing) {
MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked)
} else {
currentState.body
},
)
}
}
private fun loadInfo() {
state.update { currentState ->
currentState.copy(
body = MarketsTokenDetailsUM.Body.Loading,
)
}
modelScope.launch {
val tokenMarketInfo = getTokenMarketInfoUseCase(
appCurrency = currentAppCurrency.value,
tokenId = params.token.id,
tokenSymbol = params.token.symbol,
)
tokenMarketInfo.fold(
ifRight = { result -> updateInfo(result) },
ifLeft = {
state.update { currentState ->
if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) {
currentState.copy(
body = MarketsTokenDetailsUM.Body.Error(
onLoadRetryClick = ::onLoadRetryClicked,
),
)
} else {
currentState.copy(
body = MarketsTokenDetailsUM.Body.Nothing,
)
}
}
},
)
}
}
private fun updateInfo(newInfo: TokenMarketInfo) {
lastUpdatedTimestamp.value = DateTime.now().millis
currentTokenInfo.value = newInfo
currentQuotes.value = newInfo.quotes
val percent = newInfo.quotes.getPercentByInterval(interval = state.value.selectedInterval)
state.update { currentState ->
currentState.copy(
priceText = newInfo.quotes.currentPrice.format {
fiat(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
fiatCurrencyCode = currentAppCurrency.value.code,
).price()
},
priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval(
interval = currentState.selectedInterval,
),
priceChangeType = percent.percentChangeType(),
body = MarketsTokenDetailsUM.Body.Content(
description = descriptionConverter.convert(newInfo),
infoBlocks = infoConverter.convert(newInfo),
),
)
}
val areAllWalletsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
val networks = newInfo.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
excludedBlockchains = excludedBlockchains,
hotExcludedBlockchains = hotWalletExcludedBlockchains,
hasOnlyHotWallets = areAllWalletsHot,
)
}
networksState.value = if (networks.isNullOrEmpty()) {
TokenNetworksState.NoNetworksAvailable
} else {
TokenNetworksState.NetworksAvailable(networks)
}
chartDataProducer.runTransaction {
updateLook { currentLook ->
currentLook.copy(type = percent.percentChangeType().toChartType())
}
}
}
private suspend fun updateQuotes(newQuotes: TokenQuotes) {
val populatedNewQuotes = currentQuotes.value.populateWith(newQuotes)
quotesStateUpdater.updateQuotes(newQuotes = populatedNewQuotes)
val percent = populatedNewQuotes
.getPercentByInterval(interval = state.value.selectedInterval)
chartDataProducer.runTransaction {
updateLook {
it.copy(type = percent.percentChangeType().toChartType())
}
}
}
private fun onSelectedIntervalChange(interval: PriceChangeInterval) {
if (state.value.selectedInterval == interval) return
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.Chart,
interval = interval,
),
)
// ==================
val quotes = currentQuotes.value
val priceChangePercent = quotes.getFormattedPercentByInterval(interval)
state.update { currentState ->
currentState.copy(
priceChangePercentText = priceChangePercent,
selectedInterval = interval,
priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType()
?: PriceChangeType.NEUTRAL,
dateTimeText = getDefaultDateTimeString(interval),
)
}
loadChart(interval)
if (priceChangePercent.isEmpty()) {
loadQuotes()
}
}
@Suppress("MagicNumber")
private fun onMarkerPointSelected(markerTimestamp: BigDecimal?, price: BigDecimal?) {
val currentState = state.value
val dateTimeText = markerTimestamp?.let { timestamp ->
MarketsDateTimeFormatters.formatDateByIntervalWithMarker(
interval = currentState.selectedInterval,
markerTimestamp = timestamp,
)
} ?: getDefaultDateTimeString(currentState.selectedInterval)
val priceText = (price ?: currentQuotes.value.currentPrice).format {
fiat(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
fiatCurrencyCode = currentAppCurrency.value.code,
).price()
}
val percent = price?.let { selectedPrice ->
getChangePercentBetween(
previousPrice = selectedPrice,
currentPrice = currentQuotes.value.currentPrice,
)
} ?: currentQuotes.value.getPercentByInterval(currentState.selectedInterval)
val percentText = percent?.format { percent() }.orEmpty()
state.update { stateToUpdate ->
stateToUpdate.copy(
isMarkerSet = markerTimestamp != null,
dateTimeText = dateTimeText,
priceText = priceText,
priceChangePercentText = percentText,
priceChangeType = percent.percentChangeType(),
)
}
chartDataProducer.runTransaction {
updateLook { currentLook ->
currentLook.copy(
type = percent.percentChangeType().toChartType(),
)
}
}
}
private fun showBottomSheet(content: TangemBottomSheetConfigContent) {
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(
isShown = true,
onDismissRequest = ::hideBottomSheet,
content = content,
),
)
}
}
private fun hideBottomSheet() {
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(isShown = false),
)
}
}
private fun onLoadRetryClicked() {
val currentState = state.value
if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.ERROR) {
loadChart(currentState.selectedInterval)
}
if (currentState.body is MarketsTokenDetailsUM.Body.Error ||
currentState.body is MarketsTokenDetailsUM.Body.Nothing
) {
loadInfo()
modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS)
}
}
private fun onListedOnClick(exchangesCount: Int) {
modelScope.launch {
analyticsEventHandler.send(analyticsEventBuilder.exchangesScreenOpened())
showBottomSheet(content = ExchangesBottomSheetContent.Loading(exchangesCount))
val maybeExchanges = getTokenExchangesUseCase(tokenId = params.token.id)
// Delay to show the bottom sheet
delay(timeMillis = 400L)
updateExchangeBSContent(maybeExchanges = maybeExchanges, exchangesCount = exchangesCount)
}
}
private fun updateExchangeBSContent(
maybeExchanges: Either<Throwable, List<TokenMarketExchange>>,
exchangesCount: Int,
) {
val content = maybeExchanges
.fold(
ifLeft = {
ExchangesBottomSheetContent.Error(onRetryClick = { onListedOnClick(exchangesCount) })
},
ifRight = { exchanges ->
ExchangesBottomSheetContent.Content(
exchangeItems = ExchangeItemStateConverter.convertList(exchanges).toImmutableList(),
)
},
)
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(content = content),
)
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {
delay(timeMillis)
// Update quotes only when content is visible on the screen
isVisibleOnScreen.first { it }
loadQuotes()
}
}.saveIn(quotesJob)
}
private fun getDefaultDateTimeString(interval: PriceChangeInterval): TextReference {
return MarketsDateTimeFormatters.formatDateByInterval(
interval = interval,
startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval(
interval = interval,
currentTimestamp = lastUpdatedTimestamp.value,
),
)
}
private companion object {
const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L
}
}

View file

@ -1,45 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.markets.TokenMarketInfo
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.utils.Provider
import com.tangem.utils.converter.Converter
@Stable
internal class DescriptionConverter(
private val onReadModeClicked: (InfoBottomSheetContent) -> Unit,
private val onGeneratedAINotificationClick: () -> Unit,
private val needApplyFCARestrictions: Provider<Boolean>,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.Description?> {
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? {
if (needApplyFCARestrictions()) return null
val shortDesc = value.shortDescription ?: return null
return MarketsTokenDetailsUM.Description(
shortDescription = stringReference(shortDesc),
fullDescription = value.fullDescription?.let(::stringReference),
onReadMoreClick = {
onReadModeClicked(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_about_token_title,
wrappedList(
value.name,
),
),
body = stringReference(value.fullDescription.orEmpty()),
generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM(
onClick = onGeneratedAINotificationClick,
),
),
)
},
)
}
}

View file

@ -1,68 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.markets.TokenMarketExchange
import com.tangem.domain.markets.TokenMarketExchange.TrustScore
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
/**
* Converter from [TokenMarketExchange] to [TokenItemState]
*
[REDACTED_AUTHOR]
*/
internal object ExchangeItemStateConverter : Converter<TokenMarketExchange, TokenItemState> {
override fun convert(value: TokenMarketExchange): TokenItemState {
return TokenItemState.Content(
id = value.id,
iconState = CurrencyIconState.CoinIcon(
url = value.imageUrl,
fallbackResId = R.drawable.ic_alert_24,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(text = stringReference(value.name)),
fiatAmountState = TokenItemState.FiatAmountState.Content(
text = value.volumeInUsd.format {
fiat(
fiatCurrencyCode = "USD",
fiatCurrencySymbol = "$",
).price()
},
),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference(value = if (value.isCentralized) "CEX" else "DEX"),
),
subtitle2State = TokenItemState.Subtitle2State.LabelContent(
auditLabelUM = value.trustScore.toAuditLabelUM(),
),
onItemClick = null,
onItemLongClick = null,
)
}
private fun TrustScore.toAuditLabelUM(): AuditLabelUM {
return when (this) {
TrustScore.Risky -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_risky),
type = AuditLabelUM.Type.Prohibition,
)
TrustScore.Caution -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_caution),
type = AuditLabelUM.Type.Warning,
)
TrustScore.Trusted -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_trusted),
type = AuditLabelUM.Type.Permit,
)
}
}
}

View file

@ -1,168 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.compact
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.rawCompact
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.InsightsUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@Stable
internal class InsightsConverter(
private val appCurrency: Provider<AppCurrency>,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
private val onIntervalChanged: (PriceChangeInterval) -> Unit,
) : Converter<TokenMarketInfo.Insights, InsightsUM> {
override fun convert(value: TokenMarketInfo.Insights): InsightsUM {
return with(value) {
InsightsUM(
h24Info = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.day,
holdersChange = holdersChange?.day,
liquidityChange = liquidityChange?.day,
buyPressureChange = buyPressureChange?.day,
),
weekInfo = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.week,
holdersChange = holdersChange?.week,
liquidityChange = liquidityChange?.week,
buyPressureChange = buyPressureChange?.week,
),
monthInfo = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.month,
holdersChange = holdersChange?.month,
liquidityChange = liquidityChange?.month,
buyPressureChange = buyPressureChange?.month,
),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_insights),
body = resourceReference(
R.string.markets_insights_info_description_message,
wrappedList(value.sourceNetworks.joinToString { it.name }),
),
),
)
},
onIntervalChanged = onIntervalChanged,
)
}
}
private fun createInfoPointList(
experiencedBuyerChange: BigDecimal?,
holdersChange: BigDecimal?,
liquidityChange: BigDecimal?,
buyPressureChange: BigDecimal?,
): ImmutableList<InfoPointUM> {
return listOfNotNull(
experiencedBuyerChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = experiencedBuyerChange.convertChange(),
change = experiencedBuyerChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_experienced_buyers_full),
body = resourceReference(R.string.markets_token_details_experienced_buyers_description),
),
)
},
)
},
buyPressureChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = buyPressureChange.convertChange(isFiatValue = true),
change = buyPressureChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_buy_pressure_full),
body = resourceReference(R.string.markets_token_details_buy_pressure_description),
),
)
},
)
},
holdersChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = holdersChange.convertChange(),
change = holdersChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_holders_full),
body = resourceReference(R.string.markets_token_details_holders_description),
),
)
},
)
},
liquidityChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = liquidityChange.convertChange(),
change = liquidityChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_liquidity_full),
body = resourceReference(R.string.markets_token_details_liquidity_description),
),
)
},
)
},
).toImmutableList()
}
private fun BigDecimal.changeType(): InfoPointUM.ChangeType? {
return when {
this > BigDecimal.ZERO -> InfoPointUM.ChangeType.UP
this < BigDecimal.ZERO -> InfoPointUM.ChangeType.DOWN
else -> null
}
}
private fun BigDecimal.convertChange(isFiatValue: Boolean = false): String {
val value = if (isFiatValue) {
this.abs().format {
val currency = appCurrency()
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).compact()
}
} else {
this.abs().format {
rawCompact()
}
}
return when {
this > BigDecimal.ZERO -> StringsSigns.PLUS + value
this < BigDecimal.ZERO -> StringsSigns.MINUS + value
this == BigDecimal.ZERO -> value
else -> StringsSigns.DASH_SIGN
}
}
}

View file

@ -1,49 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.details.impl.ui.state.LinksUM.Link
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
@Stable
internal class LinksConverter(
private val onLinkClick: (LinksUM.Link) -> Unit,
) : Converter<TokenMarketInfo.Links, LinksUM> {
override fun convert(value: TokenMarketInfo.Links): LinksUM {
return LinksUM(
officialLinks = value.officialLinks?.map { it.convert() }.orEmpty().toImmutableList(),
social = value.social?.map { it.convert() }.orEmpty().toImmutableList(),
repository = value.repository?.map { it.convert() }.orEmpty().toImmutableList(),
blockchainSite = value.blockchainSite?.map { it.convert() }.orEmpty().toImmutableList(),
onLinkClick = onLinkClick,
)
}
private fun TokenMarketInfo.Link.convert(): LinksUM.Link {
return LinksUM.Link(
title = title,
iconRes = getIconById(id),
url = link,
)
}
private fun getIconById(id: String?): Int {
return when (id) {
"linkedin" -> R.drawable.ic_linkedin_24
"discord" -> R.drawable.ic_discord_24
"youtube" -> R.drawable.ic_youtube_24
"telegram" -> R.drawable.ic_telegram_24
"github" -> R.drawable.ic_github_24
"twitter" -> R.drawable.ic_twitter_24
"facebook" -> R.drawable.ic_facebook_24
"reddit" -> R.drawable.ic_reddit_24
"instagram" -> R.drawable.ic_instagram_24
"whitepaper" -> R.drawable.ic_doc_24
else -> R.drawable.ic_arrow_top_right_24
}
}
}

View file

@ -1,152 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.compact
import com.tangem.core.ui.format.bigdecimal.crypto
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.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.MetricsUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Stable
internal class MetricsConverter(
private val appCurrency: Provider<AppCurrency>,
private val tokenSymbol: String,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
) : Converter<TokenMarketInfo.Metrics, MetricsUM> {
@Suppress("LongMethod")
override fun convert(value: TokenMarketInfo.Metrics): MetricsUM {
return with(value) {
MetricsUM(
metrics = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_capitalization),
value = marketCap.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_market_capitalization_full,
),
body = resourceReference(
R.string.markets_token_details_market_capitalization_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_rating),
value = marketRating?.toString() ?: StringsSigns.DASH_SIGN,
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_market_rating_full),
body = resourceReference(R.string.markets_token_details_market_rating_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_trading_volume),
value = volume24h.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_trading_volume_full),
body = resourceReference(
R.string.markets_token_details_trading_volume_24h_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
value = fullyDilutedValuation.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_fully_diluted_valuation_full,
),
body = resourceReference(
R.string.markets_token_details_fully_diluted_valuation_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_circulating_supply),
value = circulatingSupply.formatAmount(crypto = true),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_circulating_supply_full),
body = resourceReference(
R.string.markets_token_details_circulating_supply_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_max_supply),
value = maxSupply.formatMaxSupply(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_max_supply_full),
body = resourceReference(R.string.markets_token_details_total_supply_description),
),
)
},
),
),
)
}
}
private fun BigDecimal?.formatMaxSupply(): String {
when (this) {
null -> return StringsSigns.DASH_SIGN
BigDecimal.ZERO -> return StringsSigns.INFINITY_SIGN
}
return this.formatAmount(crypto = true)
}
private fun BigDecimal?.formatAmount(crypto: Boolean = false): String {
if (this == null) return StringsSigns.DASH_SIGN
return if (crypto) {
format {
crypto(
symbol = tokenSymbol,
decimals = 2,
).compact()
}
} else {
val currency = appCurrency()
format {
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).compact()
}
}
}
}

View file

@ -1,71 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import java.math.BigDecimal
import java.math.RoundingMode
@Stable
internal class PricePerformanceConverter(
private val appCurrency: Provider<AppCurrency>,
private val onIntervalChanged: (PriceChangeInterval) -> Unit,
) {
fun convert(value: TokenMarketInfo.PricePerformance, currentPrice: BigDecimal): PricePerformanceUM {
return PricePerformanceUM(
h24 = value.day.convert(currentPrice),
month = value.month.convert(currentPrice),
all = value.allTime.convert(currentPrice),
onIntervalChanged = onIntervalChanged,
)
}
private fun TokenMarketInfo.Range?.convert(currentPrice: BigDecimal): PricePerformanceUM.Value {
if (this == null || this.low == null || this.high == null) {
return PricePerformanceUM.Value(
low = StringsSigns.DASH_SIGN,
high = StringsSigns.DASH_SIGN,
indicatorFraction = 0f,
)
}
return PricePerformanceUM.Value(
low = low.convert(),
high = high.convert(),
indicatorFraction = calculateFraction(currentPrice),
)
}
private fun BigDecimal?.convert(): String {
val currency = appCurrency()
return format {
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).price()
}
}
private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float {
val lowValue = low
val highValue = high
return when {
lowValue == null || highValue == null || highValue == BigDecimal.ZERO || currentPrice < lowValue -> 0f
currentPrice > highValue || lowValue == highValue -> 1f
else -> {
(currentPrice - lowValue).divide(highValue - lowValue, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP)
.toFloat().coerceAtMost(1f)
}
}
}
}

View file

@ -1,56 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.model.formatter.MarketsDateTimeFormatters
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
@Stable
internal class SecurityScoreConverter(
private val onSecurityScoreInfoClick: (SecurityScoreBottomSheetContent) -> Unit,
private val onSecurityScoreProviderLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit,
) : Converter<TokenMarketInfo.SecurityData, SecurityScoreUM> {
override fun convert(value: TokenMarketInfo.SecurityData): SecurityScoreUM {
val ratingsCount = value.securityScoreProviderData.size
return SecurityScoreUM(
score = value.totalSecurityScore,
description = pluralReference(
id = R.plurals.markets_token_details_based_on_ratings,
count = ratingsCount,
formatArgs = wrappedList(ratingsCount),
),
onInfoClick = {
onSecurityScoreInfoClick(
SecurityScoreBottomSheetContent(
title = resourceReference(R.string.markets_token_details_security_score),
description = resourceReference(R.string.markets_token_details_security_score_description),
providers = value.securityScoreProviderData.map { provider ->
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = provider.providerName,
lastAuditDate = provider.lastAuditDate?.let { date ->
MarketsDateTimeFormatters.formatAsDate(date.millis)
},
score = provider.securityScore,
urlData = provider.urlData?.let { urlData ->
SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = urlData.fullUrl,
rootHost = urlData.rootHost,
)
},
iconUrl = provider.iconUrl,
)
},
onProviderLinkClick = onSecurityScoreProviderLinkClick,
),
)
},
)
}
}

View file

@ -1,81 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.details.impl.ui.state.ListedOnUM
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreBottomSheetContent
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@Stable
@Suppress("LongParameterList")
internal class TokenMarketInfoConverter(
private val appCurrency: Provider<AppCurrency>,
private val needApplyFCARestrictions: Provider<Boolean>,
private val onInfoClick: (TangemBottomSheetConfigContent) -> Unit,
private val onListedOnClick: (Int) -> Unit,
onSecurityScoreInfoClick: (SecurityScoreBottomSheetContent) -> Unit,
onLinkClick: (LinksUM.Link) -> Unit,
onSecurityScoreProviderLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit,
onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit,
onInsightsIntervalChanged: (PriceChangeInterval) -> Unit,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.InformationBlocks> {
private val insightsConverter = InsightsConverter(
appCurrency = appCurrency,
onInfoClick = onInfoClick,
onIntervalChanged = onInsightsIntervalChanged,
)
private val securityScoreConverter = SecurityScoreConverter(
onSecurityScoreInfoClick = onSecurityScoreInfoClick,
onSecurityScoreProviderLinkClick = onSecurityScoreProviderLinkClick,
)
private val pricePerformanceConverter = PricePerformanceConverter(
appCurrency = appCurrency,
onIntervalChanged = onPricePerformanceIntervalChanged,
)
private val linksConverter = LinksConverter(onLinkClick = onLinkClick)
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks {
val metricsConverter = MetricsConverter(
tokenSymbol = value.symbol,
appCurrency = appCurrency,
onInfoClick = onInfoClick,
)
val exchangesAmount = value.exchangesAmount
val insights = if (needApplyFCARestrictions()) {
null
} else {
value.insights?.let { insightsConverter.convert(it) }
}
val securityScore = if (needApplyFCARestrictions()) {
null
} else {
value.securityData?.let { securityScoreConverter.convert(it) }
}
return MarketsTokenDetailsUM.InformationBlocks(
insights = insights,
securityScore = securityScore,
metrics = value.metrics?.let { metricsConverter.convert(it) },
pricePerformance = value.pricePerformance?.let { performance ->
pricePerformanceConverter.convert(
value = performance,
currentPrice = value.quotes.currentPrice,
)
},
listedOn = if (exchangesAmount != null && exchangesAmount > 0) {
ListedOnUM.Content(onClick = { onListedOnClick(exchangesAmount) }, amount = exchangesAmount)
} else {
ListedOnUM.Empty
},
links = value.links?.let { linksConverter.convert(it) },
)
}
}

View file

@ -1,76 +0,0 @@
package com.tangem.features.markets.details.impl.model.formatter
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getFiatPriceAmountWithScale
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenQuotes
import java.math.BigDecimal
import java.math.RoundingMode
internal fun TokenQuotes.getFormattedPercentByInterval(interval: PriceChangeInterval): String {
val percent = when (interval) {
PriceChangeInterval.H24 -> h24ChangePercent
PriceChangeInterval.WEEK -> weekChangePercent
PriceChangeInterval.MONTH -> monthChangePercent
PriceChangeInterval.MONTH3 -> m3ChangePercent
PriceChangeInterval.MONTH6 -> m6ChangePercent
PriceChangeInterval.YEAR -> yearChangePercent
PriceChangeInterval.ALL_TIME -> allTimeChangePercent
}
return percent?.format { percent() }.orEmpty()
}
internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): BigDecimal? {
return when (interval) {
PriceChangeInterval.H24 -> h24ChangePercent
PriceChangeInterval.WEEK -> weekChangePercent
PriceChangeInterval.MONTH -> monthChangePercent
PriceChangeInterval.MONTH3 -> m3ChangePercent
PriceChangeInterval.MONTH6 -> m6ChangePercent
PriceChangeInterval.YEAR -> yearChangePercent
PriceChangeInterval.ALL_TIME -> allTimeChangePercent
}
}
@Suppress("MagicNumber")
internal fun BigDecimal?.percentChangeType(): PriceChangeType {
val scaled = this?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
@Suppress("MagicNumber")
internal fun getChangePercentBetween(currentPrice: BigDecimal, previousPrice: BigDecimal): BigDecimal {
return if (previousPrice == BigDecimal.ZERO) {
BigDecimal.ZERO
} else {
currentPrice.subtract(previousPrice).divide(previousPrice, 4, RoundingMode.HALF_UP)
}
}
internal fun getFormattedPriceChange(currentPrice: BigDecimal, updatedPrice: BigDecimal): PriceChangeType {
val current = getFiatPriceAmountWithScale(value = currentPrice).first
val updated = getFiatPriceAmountWithScale(value = updatedPrice).first
return when {
updated > current -> PriceChangeType.UP
updated < current -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
internal fun PriceChangeType.toChartType(): MarketChartLook.Type {
return when (this) {
PriceChangeType.UP -> MarketChartLook.Type.Growing
PriceChangeType.DOWN -> MarketChartLook.Type.Falling
PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral
}
}

View file

@ -1,140 +0,0 @@
package com.tangem.features.markets.details.impl.model.formatter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.formatAsDateTime
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.impl.R
import com.tangem.utils.H24_MILLIS
import com.tangem.utils.WEEK_MILLIS
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import java.math.BigDecimal
internal object MarketsDateTimeFormatters {
private val dateTimeMMMFormatter by lazy {
DateTimeFormatters.getBestFormatterBySkeleton("dd MMM Hm")
}
private val dateFormatter = DateTimeFormatters.dateDDMMYYYY
fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String {
return when (interval) {
PriceChangeInterval.H24 -> { value: BigDecimal ->
value.toLong().formatAsDateTime(DateTimeFormatters.timeFormatter)
}
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
PriceChangeInterval.MONTH6,
-> { value ->
value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd)
}
PriceChangeInterval.YEAR -> { value ->
value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd)
}
PriceChangeInterval.ALL_TIME -> { value ->
value.toLong().formatAsDateTime(DateTimeFormatters.dateYYYY)
}
}
}
fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference {
return when (interval) {
PriceChangeInterval.H24 -> resourceReference(R.string.common_today)
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
startTimestamp.formatAsDateTime(dateTimeMMMFormatter),
),
resourceReference(R.string.common_now),
),
)
}
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
startTimestamp.formatAsDateTime(dateFormatter),
),
resourceReference(R.string.common_now),
),
)
}
PriceChangeInterval.ALL_TIME -> resourceReference(R.string.common_all)
}
}
fun formatDateByIntervalWithMarker(interval: PriceChangeInterval, markerTimestamp: BigDecimal): TextReference {
return when (interval) {
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
markerTimestamp.toLong().formatAsDateTime(dateTimeMMMFormatter),
),
resourceReference(R.string.common_now),
),
)
}
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
PriceChangeInterval.ALL_TIME,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
markerTimestamp.toLong().formatAsDateTime(dateFormatter),
),
resourceReference(R.string.common_now),
),
)
}
}
}
@Suppress("MagicNumber")
fun getStartTimestampByInterval(interval: PriceChangeInterval, currentTimestamp: Long): Long {
return when (interval) {
PriceChangeInterval.H24 -> currentTimestamp - H24_MILLIS
PriceChangeInterval.WEEK -> currentTimestamp - WEEK_MILLIS
PriceChangeInterval.MONTH -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(1).millis
PriceChangeInterval.MONTH3 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(3).millis
PriceChangeInterval.MONTH6 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(6).millis
PriceChangeInterval.YEAR -> DateTime(currentTimestamp, DateTimeZone.UTC).minusYears(1).millis
PriceChangeInterval.ALL_TIME -> 0
}
}
fun getDefaultDateTimeString(interval: PriceChangeInterval, currentTimestamp: Long): TextReference {
return formatDateByInterval(
interval = interval,
startTimestamp = getStartTimestampByInterval(
interval = interval,
currentTimestamp = currentTimestamp,
),
)
}
fun formatAsDate(timestamp: Long): String {
return timestamp.formatAsDateTime(dateFormatter)
}
}

View file

@ -1,96 +0,0 @@
package com.tangem.features.markets.details.impl.model.state
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenQuotes
import com.tangem.features.markets.details.impl.model.converters.PricePerformanceConverter
import com.tangem.features.markets.details.impl.model.formatter.*
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.utils.Provider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import org.joda.time.DateTime
import java.math.BigDecimal
internal class QuotesStateUpdater(
private val currentAppCurrency: Provider<AppCurrency>,
private val state: MutableStateFlow<MarketsTokenDetailsUM>,
private val currentQuotes: MutableStateFlow<TokenQuotes>,
private val lastUpdatedTimestamp: MutableStateFlow<Long>,
private val currentTokenInfo: MutableStateFlow<TokenMarketInfo?>,
private val onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit,
) {
private val pricePerformanceConverter = PricePerformanceConverter(
currentAppCurrency,
onIntervalChanged = onPricePerformanceIntervalChanged,
)
suspend fun updateQuotes(newQuotes: TokenQuotes) {
val triggerPriceChangeType = getFormattedPriceChange(
currentPrice = currentQuotes.value.currentPrice,
updatedPrice = newQuotes.currentPrice,
)
val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) {
triggeredEvent(
data = triggerPriceChangeType,
onConsume = {
state.update { it.copy(triggerPriceChange = consumedEvent()) }
},
)
} else {
consumedEvent()
}
val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval)
val priceChangeType = percent.percentChangeType()
// wait until marker is removed
state.first { it.isMarkerSet.not() }
currentQuotes.value = newQuotes
lastUpdatedTimestamp.value = DateTime.now().millis
state.update { stateToUpdate ->
stateToUpdate.copy(
priceText = newQuotes.currentPrice.format {
fiat(
fiatCurrencySymbol = currentAppCurrency().symbol,
fiatCurrencyCode = currentAppCurrency().code,
).price()
},
priceChangePercentText = newQuotes.getFormattedPercentByInterval(
interval = stateToUpdate.selectedInterval,
),
priceChangeType = priceChangeType,
triggerPriceChange = trigger,
dateTimeText = MarketsDateTimeFormatters.getDefaultDateTimeString(
stateToUpdate.selectedInterval,
currentTimestamp = lastUpdatedTimestamp.value,
),
body = stateToUpdate.body.updatePricePerformance(newQuotes.currentPrice),
)
}
}
private fun MarketsTokenDetailsUM.Body.updatePricePerformance(price: BigDecimal): MarketsTokenDetailsUM.Body {
val currentPricePerformance = currentTokenInfo.value?.pricePerformance ?: return this
return if (this is MarketsTokenDetailsUM.Body.Content) {
copy(
infoBlocks = infoBlocks.copy(
pricePerformance = pricePerformanceConverter.convert(currentPricePerformance, price),
),
)
} else {
this
}
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.features.markets.details.impl.model.state
import com.tangem.domain.markets.TokenMarketInfo
internal sealed class TokenNetworksState {
data object Loading : TokenNetworksState()
data object NoNetworksAvailable : TokenNetworksState()
data class NetworksAvailable(val networks: List<TokenMarketInfo.Network>) : TokenNetworksState()
}

View file

@ -1,351 +0,0 @@
package com.tangem.features.markets.details.impl.ui
import android.content.res.Configuration
import androidx.compose.animation.Animatable
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.components.*
import com.tangem.features.markets.details.impl.ui.preview.MarketsTokenDetailsPreview
import com.tangem.features.markets.details.impl.ui.state.ExchangesBottomSheetContent
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.details.impl.ui.state.SecurityScoreBottomSheetContent
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongParameterList")
@Composable
internal fun MarketsTokenDetailsContent(
state: MarketsTokenDetailsUM,
backgroundColor: Color,
addTopBarStatusBarPadding: Boolean,
onBackClick: () -> Unit,
onHeaderSizeChange: (Dp) -> Unit,
backButtonEnabled: Boolean,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
Content(
modifier = modifier,
backgroundColor = backgroundColor,
state = state,
onBackClick = onBackClick,
onHeaderSizeChange = onHeaderSizeChange,
backButtonEnabled = backButtonEnabled,
portfolioBlock = portfolioBlock,
isAccountEnabled = isAccountEnabled,
addTopBarStatusBarInsets = addTopBarStatusBarPadding,
)
when (state.bottomSheetConfig.content) {
is InfoBottomSheetContent -> InfoBottomSheet(config = state.bottomSheetConfig)
is SecurityScoreBottomSheetContent -> SecurityScoreBottomSheet(config = state.bottomSheetConfig)
is ExchangesBottomSheetContent -> ExchangesBottomSheet(config = state.bottomSheetConfig)
}
}
@Suppress("LongParameterList")
@Composable
private fun Content(
state: MarketsTokenDetailsUM,
backgroundColor: Color,
addTopBarStatusBarInsets: Boolean,
onBackClick: () -> Unit,
onHeaderSizeChange: (Dp) -> Unit,
backButtonEnabled: Boolean,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
val lazyListState = rememberLazyListState()
Column(
modifier = modifier
.drawBehind { drawRect(backgroundColor) }
.let { if (addTopBarStatusBarInsets) it.statusBarsPadding() else it }
.fillMaxSize(),
) {
TopBar(
modifier = Modifier.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
onHeaderSizeChange(coordinates.size.height.toDp())
}
}
},
lazyListState = lazyListState,
tokenName = state.tokenName,
tokenPrice = state.priceText,
isBackButtonEnabled = backButtonEnabled,
onBackClick = onBackClick,
)
SpacerH4()
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight),
) {
item("header") {
Header(
state = state,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
item { SpacerH16() }
item("intervalSelector") {
IntervalSelector(
trendInterval = state.selectedInterval,
onIntervalClick = state.onSelectedIntervalChange,
isEnabled = state.body !is MarketsTokenDetailsUM.Body.Nothing,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
item { SpacerH32() }
item("chart") {
MarketTokenDetailsChart(
modifier = Modifier.fillMaxWidth(),
backgroundColor = backgroundColor,
state = state.chartState,
)
}
item { SpacerH16() }
tokenMarketDetailsBody(
state = state.body,
isAccountEnabled = isAccountEnabled,
portfolioBlock = portfolioBlock,
)
}
}
}
@Composable
private fun TopBar(
lazyListState: LazyListState,
tokenName: String,
tokenPrice: String,
isBackButtonEnabled: Boolean,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shouldShowPriceSubtitle by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex > 1
}
}
TangemTopAppBar(
modifier = modifier,
title = tokenName,
subtitle = if (shouldShowPriceSubtitle) tokenPrice else null,
startButton = TopAppBarButtonUM.Back(
onBackClicked = onBackClick,
enabled = isBackButtonEnabled,
),
)
}
@Composable
private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
TokenPriceText(
price = state.priceText,
triggerPriceChange = state.triggerPriceChange,
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
Text(
text = state.dateTimeText.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
if (state.priceChangePercentText != null) {
PriceChangeInPercent(
valueInPercent = state.priceChangePercentText,
type = state.priceChangeType,
textStyle = TangemTheme.typography.caption2,
)
}
}
}
SpacerW4()
CoinIcon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size48),
url = state.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
}
}
@Composable
private fun TokenPriceText(
price: String,
triggerPriceChange: StateEvent<PriceChangeType>,
modifier: Modifier = Modifier,
) {
val growColor = TangemTheme.colors.text.accent
val fallColor = TangemTheme.colors.text.warning
val generalColor = TangemTheme.colors.text.primary1
val color = remember(generalColor) { Animatable(generalColor) }
EventEffect(triggerPriceChange) { changeType ->
val nextColor = when (changeType) {
PriceChangeType.UP,
-> growColor
PriceChangeType.DOWN -> fallColor
PriceChangeType.NEUTRAL -> return@EventEffect
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
}
Text(
text = price,
modifier = modifier,
color = color.value,
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.head.fontSize),
maxLines = 1,
style = TangemTheme.typography.head,
)
}
@Composable
private fun IntervalSelector(
trendInterval: PriceChangeInterval,
isEnabled: Boolean,
onIntervalClick: (PriceChangeInterval) -> Unit,
modifier: Modifier = Modifier,
) {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
PriceChangeInterval.ALL_TIME,
),
color = TangemTheme.colors.button.secondary,
initialSelectedItem = trendInterval,
onClick = onIntervalClick,
isEnabled = isEnabled,
modifier = modifier,
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = if (isEnabled) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.disabled
},
)
}
}
}
@Composable
fun PriceChangeInterval.getText(): TextReference {
return when (this) {
PriceChangeInterval.H24 -> resourceReference(R.string.markets_selector_interval_24h_title)
PriceChangeInterval.WEEK -> resourceReference(R.string.markets_selector_interval_7d_title)
PriceChangeInterval.MONTH -> resourceReference(R.string.markets_selector_interval_1m_title)
PriceChangeInterval.MONTH3 -> resourceReference(R.string.markets_selector_interval_3m_title)
PriceChangeInterval.MONTH6 -> resourceReference(R.string.markets_selector_interval_6m_title)
PriceChangeInterval.YEAR -> resourceReference(R.string.markets_selector_interval_1y_title)
PriceChangeInterval.ALL_TIME -> resourceReference(R.string.markets_selector_interval_all_title)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun MarketsTokenDetailsContent_Preview(
@PreviewParameter(MarketsTokenDetailsContentPreviewProvider::class) params: MarketsTokenDetailsUM,
) {
TangemThemePreview {
MarketsTokenDetailsContent(
state = params,
onHeaderSizeChange = {},
onBackClick = {},
backgroundColor = TangemTheme.colors.background.tertiary,
portfolioBlock = {},
backButtonEnabled = true,
isAccountEnabled = true,
addTopBarStatusBarPadding = false,
)
}
}
private class MarketsTokenDetailsContentPreviewProvider : PreviewParameterProvider<MarketsTokenDetailsUM> {
override val values: Sequence<MarketsTokenDetailsUM>
get() = sequenceOf(
MarketsTokenDetailsPreview.loadingState,
MarketsTokenDetailsPreview.contentState,
)
}
// endregion

View file

@ -1,191 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
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.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.details.impl.ui.state.ExchangesBottomSheetContent
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.toImmutableList
/**
* Exchanges bottom sheet
*
* @param config bottom sheet config
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun ExchangesBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<ExchangesBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { Title(textResId = it.titleResId, onBackClick = config.onDismissRequest) },
content = { content ->
Box {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomBarHeight),
) {
item(key = "subtitle") {
Subtitle(
subtitleRes = content.subtitleResId,
volumeReference = content.volumeReference,
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing8,
),
)
}
items(
items = content.exchangeItems,
key = TokenItemState::id,
itemContent = { TokenItem(state = it, isBalanceHidden = false) },
)
}
if (content is ExchangesBottomSheetContent.Error) {
Error(
content = content,
modifier = Modifier.align(Alignment.Center),
)
}
}
},
)
}
@Composable
private fun Title(@StringRes textResId: Int, onBackClick: () -> Unit) {
TangemTopAppBar(
title = stringResourceSafe(id = textResId),
startButton = TopAppBarButtonUM.Back(onBackClicked = onBackClick),
)
}
@Composable
private fun Subtitle(@StringRes subtitleRes: Int, volumeReference: TextReference, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
SubtitleText(textReference = resourceReference(id = subtitleRes))
SubtitleText(textReference = volumeReference)
}
}
@Composable
private fun SubtitleText(textReference: TextReference) {
Text(
text = textReference.resolveReference(),
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun Error(content: ExchangesBottomSheetContent.Error, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(id = content.message),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption1,
)
SpacerH12()
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(id = R.string.alert_button_try_again),
onClick = content.onRetryClick,
),
)
}
}
@Preview
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ExchangesBottomSheet(
@PreviewParameter(ExchangesBottomSheetContentProvider::class) content: ExchangesBottomSheetContent,
) {
TangemThemePreview {
ExchangesBottomSheet(
config = TangemBottomSheetConfig(
onDismissRequest = {},
content = content,
isShown = true,
),
)
}
}
private class ExchangesBottomSheetContentProvider : CollectionPreviewParameterProvider<ExchangesBottomSheetContent>(
listOf(
ExchangesBottomSheetContent.Loading(exchangesCount = 13),
ExchangesBottomSheetContent.Error(onRetryClick = {}),
ExchangesBottomSheetContent.Content(
exchangeItems = List(size = 13) { index ->
TokenItemState.Content(
id = index.toString(),
iconState = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.ic_facebook_24,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "OKX")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "$67.52M"),
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference(value = "CEX")),
subtitle2State = TokenItemState.Subtitle2State.LabelContent(
auditLabelUM = AuditLabelUM(
text = stringReference("Caution"),
type = AuditLabelUM.Type.Warning,
),
),
onItemClick = {},
onItemLongClick = {},
)
}
.toImmutableList(),
),
),
)

View file

@ -1,75 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.impl.R
import dev.jeziellago.compose.markdowntext.MarkdownText
@Composable
internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<InfoBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { TangemBottomSheetTitle(title = it.title) },
content = { content ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
MarkdownText(
markdown = content.body.resolveReference(),
disableLinkMovementMethod = true,
linkifyMask = 0,
syntaxHighlightColor = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2.copy(
color = TangemTheme.colors.text.secondary,
),
)
if (content.generatedAINotificationUM != null) {
AdditionalInfoNotification(
onClick = content.generatedAINotificationUM.onClick,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
SpacerH(bottomBarHeight)
}
},
)
}
@Composable
private fun AdditionalInfoNotification(onClick: () -> Unit, modifier: Modifier = Modifier) {
Notification(
config = NotificationConfig(
subtitle = TextReference.Res(id = R.string.information_generated_with_ai),
iconResId = R.drawable.ic_magic_28,
onClick = onClick,
shouldShowArrowIcon = false,
),
modifier = modifier,
subtitleColor = TangemTheme.colors.text.primary1,
containerColor = TangemTheme.colors.button.disabled,
iconTint = TangemTheme.colors.icon.accent,
)
}

View file

@ -1,186 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
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.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
@Composable
internal fun InfoPoint(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8),
horizontalAlignment = Alignment.Start,
) {
if (infoPointUM.onInfoClick != null) {
TooltipText(
text = infoPointUM.title,
onInfoClick = infoPointUM.onInfoClick,
textStyle = TangemTheme.typography.caption2,
)
} else {
Text(
text = infoPointUM.title.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row {
Text(
text = infoPointUM.value,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
if (infoPointUM.change != null) {
SpacerW4()
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size8)
.align(Alignment.CenterVertically),
imageVector = ImageVector.vectorResource(
id = when (infoPointUM.change) {
InfoPointUM.ChangeType.UP -> R.drawable.ic_arrow_up_8
InfoPointUM.ChangeType.DOWN -> R.drawable.ic_arrow_down_8
},
),
tint = when (infoPointUM.change) {
InfoPointUM.ChangeType.UP -> TangemTheme.colors.icon.accent
InfoPointUM.ChangeType.DOWN -> TangemTheme.colors.icon.warning
},
contentDescription = null,
)
}
}
}
}
@Composable
internal fun InfoPointShimmer(modifier: Modifier = Modifier, withTooltip: Boolean = false) {
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8),
horizontalAlignment = Alignment.Start,
) {
if (withTooltip) {
Box(
modifier = Modifier
.requiredHeight(TangemTheme.dimens.size16)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.caption2,
textSizeHeight = false,
)
}
} else {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.caption2,
textSizeHeight = true,
)
}
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
style = TangemTheme.typography.body1,
textSizeHeight = true,
)
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
Column(
modifier = Modifier
.width(150.dp)
.background(TangemTheme.colors.background.tertiary),
) {
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000,000",
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000,000",
onInfoClick = { },
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000",
change = InfoPointUM.ChangeType.UP,
onInfoClick = { },
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000",
change = InfoPointUM.ChangeType.DOWN,
onInfoClick = { },
),
)
}
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewShimmer() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
Column(
modifier = Modifier
.width(150.dp)
.background(TangemTheme.colors.background.tertiary),
) {
InfoPointShimmer(modifier = Modifier.fillMaxWidth())
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
}
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -1,204 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.block.information.GridItems
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.InsightsUM
import com.tangem.features.markets.details.impl.ui.getText
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) {
var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) }
InformationBlock(
modifier = modifier,
title = {
TooltipText(
text = resourceReference(R.string.markets_token_details_insights),
textStyle = TangemTheme.typography.subtitle2,
onInfoClick = state.onInfoClick,
)
},
action = {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
),
initialSelectedItem = PriceChangeInterval.H24,
onClick = { interval ->
currentInterval = interval
state.onIntervalChanged(interval)
},
modifier = Modifier.width(IntrinsicSize.Min),
) { interval ->
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
horizontal = 14.dp,
vertical = 4.dp,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = interval.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
},
content = {
val infoPoints = when (currentInterval) {
PriceChangeInterval.H24 -> state.h24Info
PriceChangeInterval.WEEK -> state.weekInfo
PriceChangeInterval.MONTH -> state.monthInfo
else -> state.h24Info
}
GridItems(
items = infoPoints,
itemContent = { infoPoint ->
InfoPoint(
modifier = Modifier.align(Alignment.CenterStart),
infoPointUM = infoPoint,
)
},
)
},
)
}
@Composable
internal fun InsightsBlockPlaceholder(modifier: Modifier = Modifier) {
val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() }
val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() }
val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4
InformationBlock(
modifier = modifier,
title = {
RectangleShimmer(
modifier = Modifier
.height(headerHeight)
.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
)
},
content = {
GridItems(
items = List(size = 4) { it }.toImmutableList(),
horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
itemContent = {
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
)
},
)
},
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
InsightsBlock(
state = InsightsUM(
h24Info = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000 000 000",
),
),
weekInfo = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000 000",
),
),
monthInfo = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000",
),
),
onInfoClick = {},
onIntervalChanged = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
actualContent = { ContentPreview() },
shimmerContent = { InsightsBlockPlaceholder() },
)
}
}

View file

@ -1,219 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.ChipShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.chip.Chip
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
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.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
contentHorizontalPadding = 0.dp,
title = {
Text(
text = stringResourceSafe(id = R.string.markets_token_details_links),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
content = {
Column {
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_official_links),
links = state.officialLinks,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_social),
links = state.social,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_repository),
links = state.repository,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_blockchain_site),
links = state.blockchainSite,
onLinkClick = state.onLinkClick,
lastBlock = true,
)
}
},
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SubBlock(
links: ImmutableList<LinksUM.Link>,
onLinkClick: (LinksUM.Link) -> Unit,
modifier: Modifier = Modifier,
lastBlock: Boolean = false,
title: String = "Official links",
) {
if (links.isEmpty()) return
DividerContainer(
modifier = modifier,
showDivider = !lastBlock,
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Text(
text = title,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
links.fastForEach { link ->
Chip(
text = stringReference(link.title),
iconResId = link.iconRes,
onClick = { onLinkClick(link) },
)
}
}
}
}
}
@Composable
fun LinksBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
contentHorizontalPadding = 0.dp,
title = {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.subtitle2,
)
},
content = {
Column {
SubBlockPlaceholder()
SubBlockPlaceholder()
SubBlockPlaceholder(lastBlock = true)
}
},
)
}
@Composable
private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolean = false) {
DividerContainer(
modifier = modifier,
showDivider = !lastBlock,
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
TextShimmer(
modifier = Modifier.width(78.dp),
style = TangemTheme.typography.caption2,
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
repeat(times = 3) {
ChipShimmer(
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
LinksBlock(
state = LinksUM(
officialLinks = persistentListOf(
LinksUM.Link(
title = "Website",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = "Website",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = "Website",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
social = persistentListOf(
LinksUM.Link(
title = "Twitter",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = "Facebook",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
repository = persistentListOf(
LinksUM.Link(
title = "Github",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
blockchainSite = persistentListOf(),
onLinkClick = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaceholderPreview() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = { LinksBlockPlaceholder() },
actualContent = { ContentPreview() },
)
}
}

View file

@ -1,138 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.R
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.details.impl.ui.state.ListedOnUM
import kotlinx.coroutines.delay
/**
* "Listed on" block
*
* @param state block state
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun ListedOnBlock(state: ListedOnUM, modifier: Modifier = Modifier) {
Box(modifier = modifier) {
InformationBlock(
title = {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = Modifier
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
},
) {
Description(
state = state,
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
)
}
if (state is ListedOnUM.Content) {
Icon(
painter = painterResource(id = R.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = TangemTheme.dimens.spacing12),
)
}
}
}
@Composable
internal fun ListedOnBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
title = {
TextShimmer(
style = TangemTheme.typography.subtitle2,
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
)
},
modifier = modifier,
) {
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.fillMaxWidth(fraction = 0.3f)
.padding(bottom = TangemTheme.dimens.spacing12),
)
}
}
@Composable
private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) {
Text(
text = state.description.resolveReference(),
modifier = modifier,
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
@Preview(widthDp = 328, heightDp = 68)
@Preview(name = "Dark Theme", widthDp = 328, heightDp = 68, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ListedOnBlock(@PreviewParameter(ListenOnUMProvider::class) state: ListedOnUM?) {
TangemThemePreview {
if (state == null) {
ListedOnBlockPlaceholder()
} else {
ListedOnBlock(state = state)
}
}
}
@Preview
@Composable
private fun Preview_ListedOnBlock_StateChanging() {
var state by remember { mutableStateOf<ListedOnUM?>(value = null) }
Preview_ListedOnBlock(state = state)
LaunchedEffect(key1 = null) {
delay(timeMillis = 3000)
state = ListedOnUM.Empty
}
}
private class ListenOnUMProvider : CollectionPreviewParameterProvider<ListedOnUM?>(
collection = listOf(
ListedOnUM.Empty,
ListedOnUM.Content(onClick = {}, amount = 5),
null,
),
)

View file

@ -1,84 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import com.tangem.common.ui.charts.MarketChart
import com.tangem.common.ui.charts.getMarketChartBottomAxisHeight
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.rememberMarketChartState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.core.ui.components.UnableToLoadData
@Composable
internal fun MarketTokenDetailsChart(
state: MarketsTokenDetailsUM.ChartState,
backgroundColor: Color,
modifier: Modifier = Modifier,
) {
val growingColor = TangemTheme.colors.icon.accent
val fallingColor = TangemTheme.colors.icon.warning
val neutralColor = TangemTheme.colors.icon.informative
val chartState = rememberMarketChartState(
dataProducer = state.dataProducer,
colorMapper = { chartType ->
when (chartType) {
MarketChartLook.Type.Growing -> growingColor
MarketChartLook.Type.Falling -> fallingColor
MarketChartLook.Type.Neutral -> neutralColor
}
},
onMarkerShown = state.onMarkerPointSelected,
)
val bottomChartAxisHeight = getMarketChartBottomAxisHeight()
Box(modifier) {
MarketChart(
modifier = Modifier.fillMaxWidth(),
state = chartState,
)
if (state.status != MarketsTokenDetailsUM.ChartState.Status.DATA) {
Box(
Modifier
.drawBehind { drawRect(backgroundColor) }
.matchParentSize()
.padding(bottom = bottomChartAxisHeight),
) {
when (state.status) {
MarketsTokenDetailsUM.ChartState.Status.LOADING -> {
CircularProgressIndicator(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.align(Alignment.Center),
color = TangemTheme.colors.text.accent,
strokeWidth = TangemTheme.dimens.size2,
)
}
MarketsTokenDetailsUM.ChartState.Status.ERROR -> {
UnableToLoadData(
modifier = Modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
)
.align(Alignment.Center),
onRetryClick = state.onLoadRetryClick,
)
}
else -> {}
}
}
}
}
}

View file

@ -1,168 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.TextButton
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.GridItems
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.MetricsUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
const val MAX_METRICS_COUNT = 6
@Composable
internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) {
var isExpanded by remember { mutableStateOf(false) }
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResourceSafe(id = R.string.markets_token_details_metrics),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
action = {
if (state.metrics.size > MAX_METRICS_COUNT) {
ShowLessMoreButton(expanded = isExpanded, onClick = { isExpanded = !isExpanded })
}
},
content = {
val metrics = if (isExpanded) {
state.metrics
} else {
state.metrics.take(MAX_METRICS_COUNT).toImmutableList()
}
GridItems(
items = metrics,
itemContent = {
InfoPoint(infoPointUM = it)
},
)
},
)
}
// TODO make TextButton clickable area smaller and remove paddings for an action in InformationBlock
@Composable
private fun ShowLessMoreButton(expanded: Boolean, onClick: () -> Unit) {
// FIXME add string resources
val text = if (expanded) {
"See less"
} else {
"See more"
}
TextButton(
text = text,
onClick = onClick,
colors = TangemButtonsDefaults.positiveButtonColors,
textStyle = TangemTheme.typography.body2,
)
}
@Composable
internal fun MetricsBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
title = {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
style = TangemTheme.typography.subtitle2,
)
},
action = {
Box(Modifier)
},
content = {
GridItems(
items = List(size = 6) { it }.toImmutableList(),
horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
itemContent = {
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
},
)
},
)
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun BlockPreview() {
TangemThemePreview {
MetricsBlock(
state = MetricsUM(
metrics = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_capitalization),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_rating),
value = "A",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_trading_volume),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_circulating_supply),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_total_supply),
value = "1.2T",
onInfoClick = {},
),
),
),
)
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
actualContent = { BlockPreview() },
shimmerContent = { MetricsBlockPlaceholder() },
)
}
}

View file

@ -1,256 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.progressbar.TangemLinearProgressIndicator
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemAnimations
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.getText
import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier = Modifier) {
var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) }
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResourceSafe(id = R.string.markets_token_details_price_performance),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
action = {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.MONTH,
PriceChangeInterval.ALL_TIME,
),
initialSelectedItem = PriceChangeInterval.H24,
onClick = { interval ->
currentInterval = interval
state.onIntervalChanged(interval)
},
modifier = Modifier.width(IntrinsicSize.Min),
) { interval ->
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
horizontal = 14.dp,
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = interval.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
},
content = {
val value = when (currentInterval) {
PriceChangeInterval.H24 -> state.h24
PriceChangeInterval.MONTH -> state.month
PriceChangeInterval.ALL_TIME -> state.all
else -> error("")
}
Content(
modifier = Modifier.fillMaxWidth(),
state = value,
)
},
)
}
@Composable
private fun Content(state: PricePerformanceUM.Value, modifier: Modifier = Modifier) {
val animatedIndicatorFraction by TangemAnimations.horizontalIndicatorAsState(
targetFraction = state.indicatorFraction,
)
Column(
modifier = modifier
.padding(vertical = TangemTheme.dimens.spacing8),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResourceSafe(R.string.markets_token_details_low),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
SpacerW8()
Text(
text = stringResourceSafe(R.string.markets_token_details_high),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
TangemLinearProgressIndicator(
modifier = Modifier
.height(TangemTheme.dimens.size6)
.fillMaxWidth(),
progress = { animatedIndicatorFraction },
color = TangemTheme.colors.text.accent,
backgroundColor = TangemTheme.colors.background.tertiary,
strokeCap = StrokeCap.Round,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
Text(
modifier = Modifier.weight(1f),
text = state.low,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.weight(1f),
text = state.high,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.End,
)
}
}
}
@Composable
internal fun PricePerformanceBlockPlaceholder(modifier: Modifier = Modifier) {
val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() }
val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() }
val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4
InformationBlock(
modifier = modifier,
title = {
RectangleShimmer(
modifier = Modifier
.height(headerHeight)
.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
)
},
content = {
Column(
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing8),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.width(35.dp),
style = TangemTheme.typography.caption2,
)
SpacerW8()
TextShimmer(
modifier = Modifier.width(35.dp),
style = TangemTheme.typography.caption2,
)
}
RectangleShimmer(
modifier = Modifier
.height(TangemTheme.dimens.size6)
.fillMaxWidth(),
radius = 27.dp,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.width(TangemTheme.dimens.size56),
style = TangemTheme.typography.body1,
)
SpacerW8()
TextShimmer(
modifier = Modifier.width(TangemTheme.dimens.size56),
style = TangemTheme.typography.body1,
)
}
}
},
)
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
PricePerformanceBlock(
modifier = Modifier,
state = PricePerformanceUM(
h24 = PricePerformanceUM.Value(
low = "\$38,5K",
high = "\$58,5K",
indicatorFraction = 0.5f,
),
month = PricePerformanceUM.Value(
low = "\$500,5K",
high = "\$5800,5K",
indicatorFraction = 0.8f,
),
all = PricePerformanceUM.Value(
low = "\$58,52",
high = "\$580,5M",
indicatorFraction = 0.2f,
),
onIntervalChanged = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaceholderPreview() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
PricePerformanceBlockPlaceholder()
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -1,96 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.annotation.FloatRange
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.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.impl.R
import kotlin.math.round
private const val STARS_COUNT = 5
@Composable
internal fun ScoreStarsBlock(
score: Float,
horizontalSpacing: Dp,
scoreTextStyle: TextStyle,
modifier: Modifier = Modifier,
) {
val rounded = score.roundTo1decimal()
val percentage = rounded / STARS_COUNT
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(horizontalSpacing),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = rounded.toString(),
style = scoreTextStyle,
color = TangemTheme.colors.text.primary1,
)
Stars(fraction = percentage)
}
}
@Suppress("MagicNumber")
@Composable
private fun Stars(@FloatRange(0.0, 1.0) fraction: Float = 0f) {
val grayColor = TangemTheme.colors.icon.inactive
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(times = 5) { i ->
Box(
modifier = Modifier.size(TangemTheme.dimens.size16),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier
.requiredSize(16.dp)
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithCache {
onDrawWithContent {
val starFraction = ((fraction - i * 0.2) / 0.2).coerceIn(0.0, 1.0)
val starFractionFloat = starFraction
.toFloat()
.roundTo1decimal()
drawContent()
drawRect(
color = grayColor,
topLeft = Offset(x = size.width * starFractionFloat, y = 0f),
size = Size(size.width * (1 - starFractionFloat), size.height),
blendMode = BlendMode.SrcIn,
)
}
},
imageVector = ImageVector.vectorResource(R.drawable.ic_star_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
}
}
@Suppress("MagicNumber")
private fun Float.roundTo1decimal(): Float {
return round(this * 10) / 10
}

View file

@ -1,134 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM
import com.tangem.features.markets.impl.R
@Composable
internal fun SecurityScoreBlock(state: SecurityScoreUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.fillMaxWidth()
.heightIn(max = TangemTheme.dimens.size72)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1F)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
TooltipText(
text = resourceReference(R.string.markets_token_details_security_score),
onInfoClick = state.onInfoClick,
textStyle = TangemTheme.typography.subtitle2,
)
Text(
text = state.description.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ScoreStarsBlock(
score = state.score,
scoreTextStyle = TangemTheme.typography.body1,
horizontalSpacing = TangemTheme.dimens.spacing8,
)
}
}
@Composable
internal fun SecurityScoreBlockPlaceholder(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.primary)
.fillMaxWidth()
.heightIn(max = TangemTheme.dimens.size72)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier
.fillMaxWidth(fraction = 0.4f)
.padding(vertical = TangemTheme.dimens.spacing2)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.subtitle2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
}
@Preview(widthDp = 328, showBackground = true)
@Preview(widthDp = 328, showBackground = true, locale = "ru")
@Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
SecurityScoreBlock(
state = SecurityScoreUM(
score = 3.5f,
description = stringReference("Based on 3 ratings"),
onInfoClick = {},
),
)
}
}
@Preview(widthDp = 328, showBackground = true)
@Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
SecurityScoreBlockPlaceholder(
modifier = Modifier.fillMaxWidth(),
)
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -1,190 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEachIndexed
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.details.impl.ui.preview.SecurityScorePreviewData
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreBottomSheetContent
@Composable
internal fun SecurityScoreBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<SecurityScoreBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { TangemBottomSheetTitle(title = it.title) },
content = { content ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
Text(
text = content.description.resolveReference(),
style = TangemTheme.typography.body2.copy(
color = TangemTheme.colors.text.secondary,
),
)
SpacerH12()
content.providers.fastForEachIndexed { index, provider ->
DividerContainer(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = content.providers.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action),
showDivider = index != content.providers.lastIndex,
) {
SecurityScoreProviderRow(
providerUM = provider,
onLinkClick = { content.onProviderLinkClick(provider) },
)
}
}
SpacerH16()
SpacerH(bottomBarHeight)
}
},
)
}
@Composable
private fun SecurityScoreProviderRow(
providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM,
onLinkClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing12)
.heightIn(min = TangemTheme.dimens.size68),
verticalAlignment = Alignment.CenterVertically,
) {
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(providerUM.iconUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
error = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
Column(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
Text(
text = providerUM.name,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
if (providerUM.lastAuditDate != null) {
Text(
text = providerUM.lastAuditDate,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
SpacerWMax()
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
modifier = Modifier.clickable(
enabled = providerUM.urlData != null,
indication = ripple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
onClick = onLinkClick,
),
) {
ScoreStarsBlock(
score = providerUM.score,
scoreTextStyle = TangemTheme.typography.body2,
horizontalSpacing = TangemTheme.dimens.spacing3,
)
UrlBlock(providerUM)
}
}
}
@Composable
private fun UrlBlock(providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM) {
val urlData = providerUM.urlData
val rootHost = urlData?.rootHost
if (urlData != null && rootHost != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
Text(
text = urlData.rootHost,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_arrow_top_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SecurityScoreBottomSheetPreview() {
TangemThemePreview {
SecurityScoreBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = SecurityScorePreviewData.bottomSheetContent,
),
)
}
}

View file

@ -1,198 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.items.DescriptionItem
import com.tangem.core.ui.components.items.DescriptionPlaceholder
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
@Suppress("CanBeNonNullable")
internal fun LazyListScope.tokenMarketDetailsBody(
state: MarketsTokenDetailsUM.Body,
isAccountEnabled: Boolean,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
when (state) {
MarketsTokenDetailsUM.Body.Loading -> {
item("description-loading") {
DescriptionPlaceholder(modifier = Modifier.blockPaddings())
}
if (portfolioBlock != null) {
item(key = "portfolio") {
portfolioBlock(Modifier.blockPaddings())
}
}
if (isAccountEnabled) {
aboutCoinHeader()
}
loadingInfoBlocks()
}
is MarketsTokenDetailsUM.Body.Content -> {
if (state.description != null) {
description(state.description)
}
if (portfolioBlock != null) {
item(key = "portfolio") {
portfolioBlock(Modifier.blockPaddings())
}
}
if (isAccountEnabled) {
aboutCoinHeader()
}
infoBlocksList(state.infoBlocks)
}
is MarketsTokenDetailsUM.Body.Error -> {
error(state)
}
MarketsTokenDetailsUM.Body.Nothing -> {
// Do nothing
}
}
}
private fun LazyListScope.error(state: MarketsTokenDetailsUM.Body.Error) {
item("body-error") {
Box(Modifier.fillMaxWidth()) {
UnableToLoadData(
modifier = Modifier
.align(Alignment.Center)
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing40,
),
onRetryClick = state.onLoadRetryClick,
)
}
}
}
private fun LazyListScope.aboutCoinHeader() {
item("aboutCoinHeader") {
Text(
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing20,
),
text = stringResourceSafe(R.string.markets_about_coin_header),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h3,
)
}
}
private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) {
item("description") {
DescriptionItem(
modifier = Modifier.blockPaddings(),
description = description.shortDescription,
hasFullDescription = description.fullDescription != null,
onReadMoreClick = description.onReadMoreClick,
)
}
}
internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks) {
if (state.insights != null) {
item("insights") {
InsightsBlock(
modifier = Modifier.blockPaddings(),
state = state.insights,
)
}
}
if (state.securityScore != null) {
item("securityScore") {
SecurityScoreBlock(
modifier = Modifier.blockPaddings(),
state = state.securityScore,
)
}
}
if (state.metrics != null) {
item("metrics") {
MetricsBlock(
modifier = Modifier.blockPaddings(),
state = state.metrics,
)
}
}
if (state.pricePerformance != null) {
item("pricePerformance") {
PricePerformanceBlock(
modifier = Modifier.blockPaddings(),
state = state.pricePerformance,
)
}
}
item(key = "listedOn") {
ListedOnBlock(
state = state.listedOn,
modifier = Modifier.blockPaddings(),
)
}
if (state.links != null) {
item("links") {
LinksBlock(
modifier = Modifier.blockPaddings(),
state = state.links,
)
}
}
}
private fun LazyListScope.loadingInfoBlocks() {
item("insights-loading") {
InsightsBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("securityScore-loading") {
SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("metrics-loading") {
MetricsBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("pricePerformance-loading") {
PricePerformanceBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item(key = "listedOn-loading") {
ListedOnBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("links-loading") {
LinksBlockPlaceholder(modifier = Modifier.blockPaddings())
}
}
@Composable
private fun Modifier.blockPaddings(): Modifier {
return this.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing12,
)
}

View file

@ -1,129 +0,0 @@
package com.tangem.features.markets.details.impl.ui.preview
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.state.*
import kotlinx.collections.immutable.persistentListOf
internal object MarketsTokenDetailsPreview {
private val infoPoint = InfoPointUM(
title = stringReference("1"),
value = "2",
change = InfoPointUM.ChangeType.DOWN,
onInfoClick = {},
)
val loadingState = MarketsTokenDetailsUM(
tokenName = "Token Name",
priceText = "$0.00000000324",
dateTimeText = stringReference("Today"),
priceChangePercentText = "52.00%",
iconUrl = "",
priceChangeType = PriceChangeType.UP,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = MarketChartDataProducer.build { },
onLoadRetryClick = {},
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = { _, _ -> },
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = { },
body = MarketsTokenDetailsUM.Body.Loading,
bottomSheetConfig = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
isMarkerSet = false,
triggerPriceChange = consumedEvent(),
)
val contentState = MarketsTokenDetailsUM(
tokenName = "Token Name",
priceText = "$0.00000000324",
dateTimeText = stringReference("Today"),
priceChangePercentText = "52.00%",
iconUrl = "",
priceChangeType = PriceChangeType.UP,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = MarketChartDataProducer.build { },
onLoadRetryClick = {},
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = { _, _ -> },
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = { },
body = MarketsTokenDetailsUM.Body.Content(
description = MarketsTokenDetailsUM.Description(
shortDescription = stringReference("markets_token_details_description_short"),
fullDescription = stringReference("markets_token_details_description_full"),
onReadMoreClick = {},
),
infoBlocks = MarketsTokenDetailsUM.InformationBlocks(
insights = InsightsUM(
h24Info = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
weekInfo = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
monthInfo = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
onInfoClick = {},
onIntervalChanged = {},
),
securityScore = SecurityScoreUM(
score = 2.3f,
description = stringReference("markets_token_details_security_score_description"),
onInfoClick = {},
),
metrics = MetricsUM(
metrics = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
),
pricePerformance = PricePerformanceUM(
h24 = PricePerformanceUM.Value(
low = "1",
high = "2",
indicatorFraction = 0.3f,
),
month = PricePerformanceUM.Value(
low = "1",
high = "2",
indicatorFraction = 0.3f,
),
all = PricePerformanceUM.Value(
low = "1",
high = "2",
indicatorFraction = 0.3f,
),
onIntervalChanged = {},
),
listedOn = ListedOnUM.Empty,
links = null,
),
),
bottomSheetConfig = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
isMarkerSet = true,
triggerPriceChange = consumedEvent(),
)
}

View file

@ -1,60 +0,0 @@
package com.tangem.features.markets.details.impl.ui.preview
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreBottomSheetContent
internal object SecurityScorePreviewData {
val bottomSheetContent = SecurityScoreBottomSheetContent(
title = stringReference("Security score"),
description = stringReference(
"Security score of a token is a metric that assesses the " +
"security level of a blockchain or token based on various factors and is compiled from " +
"the sources listed below.",
),
providers = listOf(
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "Moralis",
lastAuditDate = "21.10.2024",
score = 4.9F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://moralis.com/",
rootHost = "moralis.com",
),
iconUrl = "",
),
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "Certik",
lastAuditDate = "10.07.2024",
score = 4.6F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://certik.com/",
rootHost = "certik.com",
),
iconUrl = "",
),
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "Cyberscope",
lastAuditDate = "25.06.2023",
score = 4.5F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://cyberscope.com/",
rootHost = "cyberscope.com",
),
iconUrl = "",
),
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "TokenInsight",
lastAuditDate = "17.01.2022",
score = 4.0F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://tokeninsight.com/",
rootHost = "tokeninsight.com",
),
iconUrl = "",
),
),
onProviderLinkClick = {},
)
}

View file

@ -1,73 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.annotation.StringRes
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.plus
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.markets.impl.R
import com.tangem.utils.StringsSigns.DOT
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* Exchanges bottom sheet content
*
[REDACTED_AUTHOR]
*/
internal sealed interface ExchangesBottomSheetContent : TangemBottomSheetConfigContent {
/** Title of bottom sheet. Like, app bar. */
@get:StringRes
val titleResId: Int
get() = R.string.markets_token_details_exchanges_title
/** Subtitle */
@get:StringRes
val subtitleResId: Int
get() = R.string.markets_token_details_exchange
/** Volume info */
@get:StringRes
val volumeReference: TextReference
get() = resourceReference(id = R.string.markets_token_details_volume) +
stringReference(value = " $DOT ") +
resourceReference(id = R.string.markets_selector_interval_24h_title)
/** Exchange items */
val exchangeItems: ImmutableList<TokenItemState>
/**
* Loading state
*
* @property exchangesCount count of exchanges
*/
data class Loading(val exchangesCount: Int) : ExchangesBottomSheetContent {
override val exchangeItems: ImmutableList<TokenItemState>
get() = List(size = exchangesCount) { index -> TokenItemState.Loading(id = "loading#$index") }
.toImmutableList()
}
/**
* Content state
*
* @property exchangeItems exchanges
*/
data class Content(
override val exchangeItems: ImmutableList<TokenItemState>,
) : ExchangesBottomSheetContent
/** Error state */
data class Error(
val onRetryClick: () -> Unit,
) : ExchangesBottomSheetContent {
override val exchangeItems: ImmutableList<TokenItemState> = persistentListOf()
@StringRes
val message: Int = R.string.markets_loading_error_title
}
}

View file

@ -1,13 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
internal data class InfoBottomSheetContent(
val title: TextReference,
val body: TextReference,
val generatedAINotificationUM: GeneratedAINotificationUM? = null,
) : TangemBottomSheetConfigContent {
data class GeneratedAINotificationUM(val onClick: () -> Unit)
}

View file

@ -1,14 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.core.ui.extensions.TextReference
internal data class InfoPointUM(
val title: TextReference,
val value: String,
val change: ChangeType? = null,
val onInfoClick: (() -> Unit)? = null,
) {
enum class ChangeType {
UP, DOWN
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.domain.markets.PriceChangeInterval
import kotlinx.collections.immutable.ImmutableList
internal data class InsightsUM(
val h24Info: ImmutableList<InfoPointUM>,
val weekInfo: ImmutableList<InfoPointUM>,
val monthInfo: ImmutableList<InfoPointUM>,
val onInfoClick: () -> Unit,
val onIntervalChanged: (PriceChangeInterval) -> Unit,
)

View file

@ -1,18 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.annotation.DrawableRes
import kotlinx.collections.immutable.ImmutableList
internal data class LinksUM(
val officialLinks: ImmutableList<Link>,
val social: ImmutableList<Link>,
val repository: ImmutableList<Link>,
val blockchainSite: ImmutableList<Link>,
val onLinkClick: (Link) -> Unit,
) {
data class Link(
@DrawableRes val iconRes: Int,
val title: String,
val url: String,
)
}

View file

@ -1,44 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.markets.impl.R
/**
* "Listed on" block UI model
*
[REDACTED_AUTHOR]
*/
internal sealed interface ListedOnUM {
/** Title */
val title: TextReference
get() = resourceReference(id = R.string.markets_token_details_listed_on)
/** Description */
val description: TextReference
/** Empty state. No exchanges found */
data object Empty : ListedOnUM {
override val description = resourceReference(id = R.string.markets_token_details_empty_exchanges)
}
/**
* Content with number of exchanges
*
* @property onClick lambda be invoked when button is clicked
* @property amount amount of exchanges
*/
data class Content(
val onClick: () -> Unit,
private val amount: Int,
) : ListedOnUM {
override val description: TextReference = pluralReference(
id = R.plurals.markets_token_details_amount_exchanges,
count = amount,
formatArgs = wrappedList(amount),
)
}
}

View file

@ -1,70 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.markets.PriceChangeInterval
import java.math.BigDecimal
internal data class MarketsTokenDetailsUM(
val tokenName: String,
val priceText: String,
val iconUrl: String?,
val dateTimeText: TextReference,
val priceChangePercentText: String?,
val priceChangeType: PriceChangeType,
val selectedInterval: PriceChangeInterval,
val isMarkerSet: Boolean,
val chartState: ChartState,
val onSelectedIntervalChange: (PriceChangeInterval) -> Unit,
val bottomSheetConfig: TangemBottomSheetConfig,
val triggerPriceChange: StateEvent<PriceChangeType>,
val body: Body,
) {
data class ChartState(
val status: Status,
val dataProducer: MarketChartDataProducer,
val onLoadRetryClick: () -> Unit,
val onMarkerPointSelected: (time: BigDecimal?, price: BigDecimal?) -> Unit,
) {
enum class Status {
LOADING, ERROR, DATA
}
}
data class InformationBlocks(
val insights: InsightsUM?,
val securityScore: SecurityScoreUM?,
val metrics: MetricsUM?,
val pricePerformance: PricePerformanceUM?,
val listedOn: ListedOnUM,
val links: LinksUM?,
)
@Immutable
sealed interface Body {
data class Error(
val onLoadRetryClick: () -> Unit,
) : Body
data object Loading : Body
data class Content(
val description: Description?,
val infoBlocks: InformationBlocks,
) : Body
data object Nothing : Body
}
data class Description(
val shortDescription: TextReference,
val fullDescription: TextReference?,
val onReadMoreClick: () -> Unit,
)
}

View file

@ -1,7 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import kotlinx.collections.immutable.ImmutableList
internal data class MetricsUM(
val metrics: ImmutableList<InfoPointUM>,
)

View file

@ -1,17 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.annotation.FloatRange
import com.tangem.domain.markets.PriceChangeInterval
internal data class PricePerformanceUM(
val h24: Value,
val month: Value,
val all: Value,
val onIntervalChanged: (PriceChangeInterval) -> Unit,
) {
data class Value(
val low: String,
val high: String,
@FloatRange(from = 0.0, to = 1.0) val indicatorFraction: Float,
)
}

View file

@ -1,25 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
internal data class SecurityScoreBottomSheetContent(
val title: TextReference,
val description: TextReference,
val providers: List<SecurityScoreProviderUM>,
val onProviderLinkClick: (SecurityScoreProviderUM) -> Unit,
) : TangemBottomSheetConfigContent {
data class SecurityScoreProviderUM(
val name: String,
val lastAuditDate: String?,
val score: Float,
val urlData: UrlData?,
val iconUrl: String?,
) {
data class UrlData(
val fullUrl: String,
val rootHost: String?,
)
}
}

View file

@ -1,10 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.annotation.FloatRange
import com.tangem.core.ui.extensions.TextReference
internal data class SecurityScoreUM(
@FloatRange(from = 0.0, to = 5.0) val score: Float,
val description: TextReference,
val onInfoClick: () -> Unit,
)

View file

@ -1,102 +0,0 @@
package com.tangem.features.markets.entry.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.popWhile
import com.arkivanov.decompose.value.Value
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.entry.MarketsEntryComponent
import com.tangem.features.markets.entry.impl.MarketsEntryChildFactory.Child
import com.tangem.features.markets.entry.impl.ui.EntryBottomSheetContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Stable
internal class DefaultMarketsEntryComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
private val marketsEntryChildFactory: MarketsEntryChildFactory,
) : MarketsEntryComponent, AppComponentContext by context {
private val stackNavigation = StackNavigation<Child>()
private val innerRouter = InnerRouter<Child>(
stackNavigation = stackNavigation,
popCallback = { onChildBack() },
)
private val stack: Value<ChildStack<Child, Any>> = childStack(
key = "main",
source = stackNavigation,
serializer = Child.serializer(),
initialConfiguration = Child.TokenList,
handleBackButton = false,
childFactory = { configuration, factoryContext ->
marketsEntryChildFactory.createChild(
child = configuration,
appComponentContext = childByContext(
componentContext = factoryContext,
router = innerRouter,
),
onTokenClick = ::marketsListTokenSelected,
)
},
)
@Suppress("LongMethod")
@Composable
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
EntryBottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = onHeaderSizeChange,
stackState = stack.subscribeAsState(),
modifier = modifier,
)
}
@OptIn(ExperimentalDecomposeApi::class)
private fun marketsListTokenSelected(token: TokenMarketParams, appCurrency: AppCurrency) {
innerRouter.push(
route = Child.TokenDetails(
params = MarketsTokenDetailsComponent.Params(
token = token,
appCurrency = appCurrency,
shouldShowPortfolio = true,
analyticsParams = MarketsTokenDetailsComponent.AnalyticsParams(
blockchain = null,
source = "Market",
),
),
),
)
}
private fun onChildBack() {
if (stack.value.active.configuration !is Child.TokenList) {
stackNavigation.popWhile { it != Child.TokenList }
}
}
@AssistedFactory
interface Factory : MarketsEntryComponent.Factory {
override fun create(context: AppComponentContext): DefaultMarketsEntryComponent
}
}

View file

@ -1,52 +0,0 @@
package com.tangem.features.markets.entry.impl
import androidx.compose.runtime.Immutable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.tokenlist.MarketsTokenListComponent
import kotlinx.serialization.Serializable
import javax.inject.Inject
internal class MarketsEntryChildFactory @Inject constructor(
private val tokenListComponentFactory: MarketsTokenListComponent.FactoryBottomSheet,
private val tokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory,
) {
@Serializable
@Immutable
sealed interface Child : Route {
@Serializable
@Immutable
data object TokenList : Child
@Serializable
@Immutable
data class TokenDetails(val params: MarketsTokenDetailsComponent.Params) : Child
}
fun createChild(
child: Child,
appComponentContext: AppComponentContext,
onTokenClick: (TokenMarketParams, AppCurrency) -> Unit,
): Any {
return when (child) {
is Child.TokenDetails -> {
tokenDetailsComponentFactory.create(
context = appComponentContext,
params = child.params,
)
}
is Child.TokenList -> {
tokenListComponentFactory.create(
context = appComponentContext,
params = Unit,
onTokenClick = onTokenClick,
)
}
}
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.features.markets.entry.impl.di
import com.tangem.features.markets.entry.MarketsEntryComponent
import com.tangem.features.markets.entry.impl.DefaultMarketsEntryComponent
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 ComponentModule {
@Binds
@Singleton
fun bindMarketsEntryComponent(factory: DefaultMarketsEntryComponent.Factory): MarketsEntryComponent.Factory
}

View file

@ -1,128 +0,0 @@
package com.tangem.features.markets.entry.impl.ui
import androidx.compose.animation.Animatable
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector4D
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.entry.impl.MarketsEntryChildFactory
import com.tangem.features.markets.tokenlist.MarketsTokenListComponent
@Composable
internal fun EntryBottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
stackState: State<ChildStack<MarketsEntryChildFactory.Child, Any>>,
modifier: Modifier = Modifier,
) {
val primary = TangemTheme.colors.background.primary
val backgroundColor = remember { Animatable(primary) }
LocalMainBottomSheetColor.current.value = backgroundColor.value
Children(
stack = stackState.value,
animation = stackAnimation(slide()),
modifier = modifier,
) { child ->
when (child.configuration) {
is MarketsEntryChildFactory.Child.TokenDetails -> {
(child.instance as MarketsTokenDetailsComponent).BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = onHeaderSizeChange,
modifier = Modifier,
)
}
is MarketsEntryChildFactory.Child.TokenList -> {
(child.instance as MarketsTokenListComponent).BottomSheetContent(
bottomSheetState = bottomSheetState,
onHeaderSizeChange = onHeaderSizeChange,
modifier = Modifier,
)
}
}
}
val activeChild = stackState.value.active.configuration
BackgroundColorEffects(
activeChild = activeChild,
backgroundColor = backgroundColor,
bottomSheetState = bottomSheetState,
)
}
@Composable
private fun BackgroundColorEffects(
activeChild: MarketsEntryChildFactory.Child,
backgroundColor: Animatable<Color, AnimationVector4D>,
bottomSheetState: State<BottomSheetState>,
) {
val primary = TangemTheme.colors.background.primary
val tertiary = TangemTheme.colors.background.tertiary
// Order of LaunchedEffects is important here
LaunchedEffect(activeChild) {
when (activeChild) {
is MarketsEntryChildFactory.Child.TokenDetails -> {
backgroundColor.animateTo(
tertiary,
animationSpec = tween(durationMillis = 500),
)
}
is MarketsEntryChildFactory.Child.TokenList -> {
backgroundColor.animateTo(
primary,
animationSpec = tween(durationMillis = 500),
)
}
}
}
LaunchedEffect(bottomSheetState.value) {
if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) {
when (bottomSheetState.value) {
BottomSheetState.EXPANDED -> {
backgroundColor.animateTo(
tertiary,
animationSpec = tween(durationMillis = 100),
)
}
BottomSheetState.COLLAPSED -> {
backgroundColor.animateTo(
primary,
animationSpec = tween(durationMillis = 100),
)
}
}
}
}
LaunchedEffect(primary, tertiary) {
if (backgroundColor.isRunning) return@LaunchedEffect
when (activeChild) {
is MarketsEntryChildFactory.Child.TokenDetails -> {
backgroundColor.snapTo(tertiary)
}
is MarketsEntryChildFactory.Child.TokenList -> {
backgroundColor.snapTo(primary)
}
}
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.features.markets.portfolio.add.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
internal interface AddToPortfolioComponent : ComposableBottomSheetComponent {
data class Params(
val addToPortfolioManager: AddToPortfolioManager,
val callback: Callback,
)
interface Callback {
fun onDismiss()
}
interface Factory : ComponentFactory<Params, AddToPortfolioComponent>
}

View file

@ -1,38 +0,0 @@
package com.tangem.features.markets.portfolio.add.api
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent.AnalyticsParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal interface AddToPortfolioManager {
val token: TokenMarketParams
val analyticsParams: AnalyticsParams?
val portfolioFetcher: PortfolioFetcher
val state: StateFlow<State>
val allAvailableNetworks: Flow<List<TokenMarketInfo.Network>>
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>)
sealed interface State {
data object Init : State
data class AvailableToAdd(
val availableToAddData: AvailableToAddData,
) : State
data object NothingToAdd : State
}
interface Factory {
fun create(
scope: CoroutineScope,
token: TokenMarketParams,
analyticsParams: AnalyticsParams?,
): AddToPortfolioManager
}
}

View file

@ -1,62 +0,0 @@
package com.tangem.features.markets.portfolio.add.api
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
internal data class AvailableToAddData(
val availableToAddWallets: Map<UserWalletId, AvailableToAddWallet>,
) {
val isAvailableToAdd: Boolean = availableToAddWallets.values.any { item -> item.isAvailableToAdd }
val isSinglePortfolio: Boolean
get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1
}
internal data class AvailableToAddWallet(
val userWallet: UserWallet,
val accounts: List<AccountStatus>,
val availableNetworks: Set<TokenMarketInfo.Network>,
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount>,
) {
val isAvailableToAdd: Boolean = availableToAddAccounts.values.any { item -> item.isAvailableToAdd }
}
@Serializable
internal data class AvailableToAddAccount(
val account: AccountStatus,
val availableNetworks: Set<TokenMarketInfo.Network>,
val addedNetworks: Set<Network>,
) {
val isSingleNetwork: Boolean
get() = availableNetworks.size == 1
val availableToAddNetworks: Set<TokenMarketInfo.Network> = availableNetworks
.filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } }
.toSet()
val isAvailableToAdd: Boolean = availableToAddNetworks.isNotEmpty()
val addedMarketNetworks: Set<TokenMarketInfo.Network> = availableNetworks
.filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } }
.toSet()
}
@Serializable
internal data class SelectedPortfolio(
val userWallet: UserWallet,
val account: AvailableToAddAccount,
val isAccountMode: Boolean,
val hasMorePortfoliosAvailable: Boolean,
)
internal data class SelectedNetwork(
val selectedNetwork: TokenMarketInfo.Network,
val cryptoCurrency: CryptoCurrency,
val hasMoreNetworksAvailable: Boolean,
)

View file

@ -1,55 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.markets.portfolio.add.api.SelectedNetwork
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import com.tangem.features.markets.portfolio.add.impl.model.AddTokenModel
import com.tangem.common.ui.addtoken.AddTokenContent
import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class AddTokenComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableContentComponent {
private val model: AddTokenModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val um = state.value ?: return
AddTokenContent(
modifier = modifier,
state = um,
)
}
data class Params(
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val selectedPortfolio: Flow<SelectedPortfolio>,
val selectedNetwork: Flow<SelectedNetwork>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onChangeNetworkClick()
fun onChangePortfolioClick()
fun onTokenAdded(status: CryptoCurrencyStatus)
}
@AssistedFactory
interface Factory : ComponentFactory<Params, AddTokenComponent> {
override fun create(context: AppComponentContext, params: Params): AddTokenComponent
}
}

View file

@ -1,45 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl
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.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import com.tangem.features.markets.portfolio.add.impl.model.ChooseNetworkModel
import com.tangem.features.markets.portfolio.add.impl.ui.ChooseNetworkContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class ChooseNetworkComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableContentComponent {
private val model: ChooseNetworkModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ChooseNetworkContent(state)
}
data class Params(
val selectedPortfolio: SelectedPortfolio,
val callbacks: Callbacks,
)
interface Callbacks {
fun onNetworkSelected(network: TokenMarketInfo.Network)
}
@AssistedFactory
interface Factory : ComponentFactory<Params, ChooseNetworkComponent> {
override fun create(context: AppComponentContext, params: Params): ChooseNetworkComponent
}
}

View file

@ -1,210 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.backStack
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.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent.Params
import com.tangem.features.markets.portfolio.add.impl.model.AddToPortfolioModel
import com.tangem.features.markets.portfolio.add.impl.model.AddToPortfolioRoutes
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
addTokenComponentFactory: AddTokenComponent.Factory,
tokenActionsComponentFactory: TokenActionsComponent.Factory,
private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory,
) : AppComponentContext by context, AddToPortfolioComponent {
private val model: AddToPortfolioModel = getOrCreateModel(params)
private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create(
context = child("portfolioSelectorComponent"),
params = PortfolioSelectorComponent.Params(
portfolioFetcher = model.portfolioFetcher,
controller = model.portfolioSelectorController,
),
)
private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create(
context = child("addTokenComponent"),
params = AddTokenComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
selectedPortfolio = model.selectedPortfolio,
selectedNetwork = model.selectedNetwork,
),
)
private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create(
context = child("tokenActionsComponent"),
params = TokenActionsComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
data = model.tokenActionsData,
),
)
private val childStack = childStack(
key = "addToPortfolioStack",
handleBackButton = true,
source = model.navigation,
serializer = AddToPortfolioRoutes.serializer(),
initialStack = { model.currentStack },
childFactory = ::contentChild,
)
private fun onBack() {
if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss()
}
override fun dismiss() {
params.callback.onDismiss()
}
@Composable
override fun BottomSheet() {
val stack by childStack.subscribeAsState()
val contentStack = remember { mutableStateOf(stack) }
val currentRoute = stack.active.configuration
val isNotEmpty = currentRoute != AddToPortfolioRoutes.Empty
if (isNotEmpty) {
contentStack.value = stack
}
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
scrollableContent = false,
onBack = ::onBack,
config = TangemBottomSheetConfig(
isShown = isNotEmpty,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors.background.tertiary,
title = { state ->
AnimatedContent(targetState = contentStack.value) { stack ->
BottomSheetTitle(
stack = stack,
onBackClick = ::onBack,
modifier = Modifier.fillMaxWidth(),
)
}
},
content = { state ->
AnimatedContent(targetState = contentStack.value) { stack ->
val paddingModifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
)
val isScrollableContent = when (stack.active.configuration) {
AddToPortfolioRoutes.PortfolioSelector -> false
AddToPortfolioRoutes.AddToken,
AddToPortfolioRoutes.Empty,
is AddToPortfolioRoutes.NetworkSelector,
AddToPortfolioRoutes.TokenActions,
-> true
}
if (isScrollableContent) {
Column(
modifier = paddingModifier.verticalScroll(rememberScrollState()),
) {
stack.active.instance.Content(modifier = Modifier)
}
} else {
stack.active.instance.Content(modifier = paddingModifier)
}
}
},
)
}
@Composable
private fun BottomSheetTitle(
stack: ChildStack<AddToPortfolioRoutes, ComposableContentComponent>,
onBackClick: (() -> Unit),
modifier: Modifier = Modifier,
) {
val title: TextReference = when (stack.active.configuration) {
AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token)
AddToPortfolioRoutes.Empty -> TextReference.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network)
AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token)
AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent)
.title.collectAsStateWithLifecycle().value
}
val startIconRes: Int?
val endIconRes: Int?
if (stack.backStack.isNotEmpty()) {
startIconRes = R.drawable.ic_back_24
endIconRes = null
} else {
startIconRes = null
endIconRes = R.drawable.ic_close_24
}
TangemModalBottomSheetTitle(
modifier = modifier,
title = title,
startIconRes = startIconRes,
endIconRes = endIconRes,
onStartClick = onBackClick,
onEndClick = onBackClick,
)
}
private fun contentChild(
config: AddToPortfolioRoutes,
componentContext: ComponentContext,
): ComposableContentComponent = when (config) {
AddToPortfolioRoutes.AddToken -> addTokenComponent
AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent
AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create(
context = childByContext(componentContext),
params = ChooseNetworkComponent.Params(
selectedPortfolio = config.selectedPortfolio,
callbacks = model,
),
)
}
@AssistedFactory
interface Factory : AddToPortfolioComponent.Factory {
override fun create(context: AppComponentContext, params: Params): DefaultAddToPortfolioComponent
}
}

View file

@ -1,79 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
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.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.features.markets.portfolio.add.impl.model.TokenActionsModel
import com.tangem.features.markets.portfolio.add.impl.ui.TokenActionsContent
import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class TokenActionsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : AppComponentContext by context, ComposableContentComponent {
private val model: TokenActionsModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TokenReceiveConfig.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val tokenActionsUM = state.value ?: return
TokenActionsContent(
modifier = modifier,
state = tokenActionsUM,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: TokenReceiveConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
data class Params(
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val data: Flow<PortfolioData.CryptoCurrencyData>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onLaterClick()
}
@AssistedFactory
interface Factory : ComponentFactory<Params, TokenActionsComponent> {
override fun create(context: AppComponentContext, params: Params): TokenActionsComponent
}
}

View file

@ -1,119 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.converter
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.markets.portfolio.add.api.AvailableToAddAccount
import com.tangem.features.markets.portfolio.add.api.AvailableToAddData
import com.tangem.features.markets.portfolio.add.api.AvailableToAddWallet
import javax.inject.Inject
internal class AvailableToAddDataConverter @Inject constructor(
private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
) {
suspend fun convert(
balances: Map<UserWalletId, PortfolioFetcher.PortfolioBalance>,
availableNetworks: Set<TokenMarketInfo.Network>,
marketParams: TokenMarketParams,
): AvailableToAddData {
suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? {
val currencies = availableNetworks
.mapNotNull { network ->
createCryptoCurrency(
userWallet = wallet,
network = network,
marketParams = marketParams,
account = this.account,
)
}
if (currencies.isEmpty()) return null
val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, currencies)
.fold(
ifEmpty = { emptySet() },
ifSome = { map ->
map.values.flatMapTo(hashSetOf()) { statuses ->
statuses.map { it.currency.network }
}
},
)
return AvailableToAddAccount(
account = this,
availableNetworks = availableNetworks,
addedNetworks = addedNetworks,
)
}
suspend fun getAvailableToAddWallet(
entry: Map.Entry<UserWalletId, PortfolioFetcher.PortfolioBalance>,
): AvailableToAddWallet {
val (_, balance) = entry
val wallet = balance.userWallet
val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks)
val accounts = balance.accountsBalance.accountStatuses
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount> = accounts
.mapNotNull { accountStatus ->
val availableToAddAccount = accountStatus.getAvailableToAddAccount(wallet) ?: return@mapNotNull null
accountStatus.account.accountId to availableToAddAccount
}
.toMap()
return AvailableToAddWallet(
userWallet = wallet,
accounts = accounts,
availableNetworks = filteredNetworks,
availableToAddAccounts = availableToAddAccounts,
)
}
val availableToAddWallets: Map<UserWalletId, AvailableToAddWallet> = balances
.map { entry ->
val (walletId, _) = entry
val availableToAddWallet = getAvailableToAddWallet(entry)
walletId to availableToAddWallet
}
.filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() }
.toMap()
return AvailableToAddData(
availableToAddWallets = availableToAddWallets,
)
}
private fun UserWallet.filteredAvailableNetworks(networks: Set<TokenMarketInfo.Network>) =
filterAvailableNetworksForWalletUseCase(
userWalletId = this.walletId,
networks = networks,
)
private suspend fun createCryptoCurrency(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
marketParams: TokenMarketParams,
account: Account,
): CryptoCurrency? {
val derivationIndex = when (account) {
is Account.CryptoPortfolio -> account.derivationIndex
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = marketParams,
network = network,
accountIndex = derivationIndex,
)
}
}

View file

@ -1,21 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.di
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager
import com.tangem.features.markets.portfolio.add.impl.DefaultAddToPortfolioComponent
import com.tangem.features.markets.portfolio.add.impl.ui.DefaultAddToPortfolioManager
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface AddToPortfolioComponentModule {
@Binds
fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory
@Binds
fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory
}

View file

@ -1,38 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.markets.portfolio.add.impl.model.AddToPortfolioModel
import com.tangem.features.markets.portfolio.add.impl.model.AddTokenModel
import com.tangem.features.markets.portfolio.add.impl.model.ChooseNetworkModel
import com.tangem.features.markets.portfolio.add.impl.model.TokenActionsModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface AddToPortfolioModelModule {
@Binds
@IntoMap
@ClassKey(AddTokenModel::class)
fun addTokenModel(model: AddTokenModel): Model
@Binds
@IntoMap
@ClassKey(AddToPortfolioModel::class)
fun addToPortfolioModel(model: AddToPortfolioModel): Model
@Binds
@IntoMap
@ClassKey(TokenActionsModel::class)
fun tokenActionsModel(model: TokenActionsModel): Model
@Binds
@IntoMap
@ClassKey(ChooseNetworkModel::class)
fun chooseNetworkModel(model: ChooseNetworkModel): Model
}

View file

@ -1,381 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.popToFirst
import com.arkivanov.decompose.router.stack.pushNew
import com.arkivanov.decompose.router.stack.replaceAll
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.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.account.PortfolioSelectorController
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.api.*
import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent
import com.tangem.features.markets.portfolio.add.impl.ChooseNetworkComponent
import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val TOKEN_ACTIONS_DELAY = 500L
@ModelScoped
@Suppress("LongParameterList")
internal class AddToPortfolioModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val callbackDelegate: AddToPortfolioCallbackDelegate,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
val portfolioSelectorController: PortfolioSelectorController,
) : Model(),
ChooseNetworkComponent.Callbacks by callbackDelegate,
TokenActionsComponent.Callbacks by callbackDelegate,
AddTokenComponent.Callbacks by callbackDelegate {
private val params = paramsContainer.require<AddToPortfolioComponent.Params>()
val navigation = StackNavigation<AddToPortfolioRoutes>()
var currentStack = listOf<AddToPortfolioRoutes>(AddToPortfolioRoutes.Empty)
/* Flows that hold state and provide it to child models */
val selectedNetwork: MutableSharedFlow<SelectedNetwork> = replayMutableSharedFlow()
val selectedPortfolio: MutableSharedFlow<SelectedPortfolio> = replayMutableSharedFlow()
val tokenActionsData: MutableSharedFlow<PortfolioData.CryptoCurrencyData> = replayMutableSharedFlow()
private val addToPortfolioManager = params.addToPortfolioManager
val portfolioFetcher = addToPortfolioManager.portfolioFetcher
val eventBuilder = PortfolioAnalyticsEvent.EventBuilder(
token = addToPortfolioManager.token,
source = addToPortfolioManager.analyticsParams?.source,
)
val featureData: Flow<AddToPortfolioManager.State> = combineFeatureData()
init {
navigation.subscribe { currentStack = it.transformer.invoke(currentStack) }
startAddToPortfolioFlow()
}
private fun <T> replayMutableSharedFlow() = MutableSharedFlow<T>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@Suppress("LongMethod")
private fun startAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishFlow() {
params.callback.onDismiss()
channel.close()
}
val featureDataFlow: StateFlow<AvailableToAddData> = featureData
.filterIsInstance<AddToPortfolioManager.State.AvailableToAdd>()
.map { it.availableToAddData }
.distinctUntilChanged()
.stateIn(this)
val isAccountMode = portfolioSelectorController.isAccountModeSync()
// use snapshot data, looks like we dont need to remap at runtime
val data = featureDataFlow.value
// you must control it via [AddToPortfolioManager.state]
if (!data.isAvailableToAdd) {
finishFlow()
return@channelFlow
}
// init data flows, emits on user/code selection, updates state holder
val firstSelectedPortfolio = setupPortfolioFlow(data)
.onEach { selectedPortfolio.emit(it) }
val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio)
.onEach { selectedNetwork.emit(it) }
val isSinglePortfolio = data.isSinglePortfolio
if (isSinglePortfolio) {
val accountId = data.availableToAddWallets.values.first()
.availableToAddAccounts.values.first()
.account.account.accountId
// force select a portfolio, triggers [selectedPortfolio]
portfolioSelectorController.selectAccount(accountId)
} else {
logAccountSelector(isAccountMode)
navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector)
}
val firstPartOfNavigation: Job = firstSelectedPortfolio
.onEach { portfolio ->
val isSingleAvailableNetwork = portfolio.account.isSingleNetwork
when {
// force select a network, triggers [selectedNetwork]
isSingleAvailableNetwork -> {
val singleNetwork = portfolio.account.availableToAddNetworks.first()
callbackDelegate.onNetworkSelected(singleNetwork)
}
// it's important to control root screen, UI depends on it(close/arrow icon)
isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio))
else -> navigation.pushNew(routeToNetworkSelector(portfolio))
}
}
.launchIn(this)
// main flow that combine all require data
val allRequireForAdd = combine(
flow = firstSelectedNetwork,
flow2 = firstSelectedPortfolio,
transform = { a, b -> a to b },
)
// suspend until all required data is selected
allRequireForAdd.first()
// line of navigation to AddToken screen is finished; cancel the job, select a new root screen
firstPartOfNavigation.cancel()
navigation.replaceAll(AddToPortfolioRoutes.AddToken)
var middleNavigationJob: Job? = null
// handle actions from AddToken screen
callbackDelegate.onChangeNetworkClick.receiveAsFlow()
.onEach {
middleNavigationJob?.cancel()
middleNavigationJob = changeNetworkNavigationFlow()
.launchIn(this)
val route = routeToNetworkSelector(selectedPortfolio.first())
navigation.pushNew(route)
}
.launchIn(this)
// handle actions from AddToken screen
callbackDelegate.onChangePortfolioClick.receiveAsFlow()
.onEach {
middleNavigationJob?.cancel()
middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this)
logAccountSelector(isAccountMode)
navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector)
}
.launchIn(this)
// suspend until token is added
val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first()
middleNavigationJob?.cancel()
val selectedPortfolio = selectedPortfolio.first()
messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added)))
setupTokenActionsFlow(selectedPortfolio, addedToken)
.onEach { cryptoCurrencyData ->
tokenActionsData.emit(cryptoCurrencyData)
navigation.replaceAll(AddToPortfolioRoutes.TokenActions)
}
.onEmpty { finishFlow() }
.launchIn(this)
callbackDelegate.onLaterClick.receiveAsFlow().first()
finishFlow()
}
.catch { error ->
Timber.e(error)
params.callback.onDismiss()
}
.launchIn(modelScope)
}
private fun logAccountSelector(isAccountMode: Boolean) {
if (isAccountMode) {
analyticsEventHandler.send(eventBuilder.popupToChooseAccount())
}
}
private fun changeNetworkNavigationFlow(): Flow<SelectedNetwork> {
return setupNetworkFlow(selectedPortfolio)
.onEach { newNetwork ->
selectedNetwork.emit(newNetwork)
navigation.popToFirst()
}
}
private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow<Unit> = flow {
val selectedPortfolioValue = selectedPortfolio.first()
val selectedAccount = selectedPortfolioValue.account.account.account.accountId
portfolioSelectorController.selectAccount(selectedAccount)
val changedPortfolio = setupPortfolioFlow(data)
.drop(1)
.onEach { portfolio ->
val isSingleAvailableNetwork = portfolio.account.isSingleNetwork
if (isSingleAvailableNetwork) {
val singleNetwork = portfolio.account.availableToAddNetworks.first()
callbackDelegate.onNetworkSelected(singleNetwork)
} else {
navigation.pushNew(routeToNetworkSelector(portfolio))
}
}
val changedNetwork = setupNetworkFlow(changedPortfolio)
combine(
flow = changedPortfolio,
flow2 = changedNetwork,
transform = { newPortfolio, newNetwork ->
selectedPortfolio.tryEmit(newPortfolio)
selectedNetwork.tryEmit(newNetwork)
navigation.popToFirst()
},
).collect { emit(it) }
}
private fun setupTokenActionsFlow(
selectedPortfolio: SelectedPortfolio,
addedToken: CryptoCurrencyStatus,
): Flow<PortfolioData.CryptoCurrencyData> {
val timeFlow = channelFlow {
val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) }
getCryptoCurrencyActionsUseCase(
currency = addedToken.currency,
accountId = selectedPortfolio.account.account.account.accountId,
).onEach { state ->
val requestedQuickActions = toQuickActions(state.states)
when {
requestedQuickActions.isNotEmpty() -> {
timerJob.cancel()
send(state)
}
// wait any requestedQuickActions while timer active
timerJob.isActive -> Unit
else -> close()
}
}.collect()
}
return timeFlow.map { actionsState ->
PortfolioData.CryptoCurrencyData(
userWallet = selectedPortfolio.userWallet,
status = actionsState.cryptoCurrencyStatus,
actions = actionsState.states,
)
}
}
private fun setupPortfolioFlow(data: AvailableToAddData): Flow<SelectedPortfolio> = combine(
flow = portfolioSelectorController.isAccountMode,
flow2 = portfolioSelectorController.selectedAccount,
transform = { isAccountMode, selectedAccountId ->
selectedAccountId ?: return@combine null
val availableToAddWallets =
data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null
val availableToAddAccount =
availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null
if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged())
SelectedPortfolio(
isAccountMode = isAccountMode,
userWallet = availableToAddWallets.userWallet,
account = availableToAddAccount,
hasMorePortfoliosAvailable = !data.isSinglePortfolio,
)
},
)
.filterNotNull()
private fun setupNetworkFlow(selectedPortfolioFlow: Flow<SelectedPortfolio>): Flow<SelectedNetwork> = combine(
flow = selectedPortfolioFlow,
flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(),
transform = transform@{ selectedPortfolio, selectedNetwork ->
SelectedNetwork(
cryptoCurrency = createCryptoCurrency(
userWallet = selectedPortfolio.userWallet,
network = selectedNetwork,
account = selectedPortfolio.account,
) ?: return@transform null,
selectedNetwork = selectedNetwork,
hasMoreNetworksAvailable = !selectedPortfolio.account.isSingleNetwork,
)
},
)
.filterNotNull()
private suspend fun createCryptoCurrency(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
account: AvailableToAddAccount,
): CryptoCurrency? {
val accountIndex = when (account.account) {
is AccountStatus.CryptoPortfolio -> account.account.account.derivationIndex
is AccountStatus.Payment -> TODO("[REDACTED_JIRA]")
}
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = addToPortfolioManager.token,
network = network,
accountIndex = accountIndex,
)
}
private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector {
return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio)
}
private fun combineFeatureData() = addToPortfolioManager.state.onEach { state ->
when (state) {
is AddToPortfolioManager.State.AvailableToAdd ->
portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus ->
val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId]
?: return@isEnabled false
val isAvailableAccount =
availableWallet.availableToAddAccounts[accountStatus.account.accountId]
?.isAvailableToAdd == true
return@isEnabled isAvailableAccount
}
AddToPortfolioManager.State.Init,
AddToPortfolioManager.State.NothingToAdd,
-> Unit
}
}
}
@ModelScoped
internal class AddToPortfolioCallbackDelegate @Inject constructor() :
ChooseNetworkComponent.Callbacks,
TokenActionsComponent.Callbacks,
AddTokenComponent.Callbacks {
val onNetworkSelected = Channel<TokenMarketInfo.Network>()
val onLaterClick = Channel<Unit>()
val onChangeNetworkClick = Channel<Unit>()
val onChangePortfolioClick = Channel<Unit>()
val onTokenAdded = Channel<CryptoCurrencyStatus>()
override fun onNetworkSelected(network: TokenMarketInfo.Network) {
onNetworkSelected.trySend(network)
}
override fun onLaterClick() {
onLaterClick.trySend(Unit)
}
override fun onChangeNetworkClick() {
onChangeNetworkClick.trySend(Unit)
}
override fun onChangePortfolioClick() {
onChangePortfolioClick.trySend(Unit)
}
override fun onTokenAdded(status: CryptoCurrencyStatus) {
onTokenAdded.trySend(status)
}
}

View file

@ -1,28 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.model
import androidx.compose.runtime.Immutable
import com.tangem.core.decompose.navigation.Route
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import kotlinx.serialization.Serializable
@Serializable
@Immutable
internal sealed interface AddToPortfolioRoutes : Route {
@Serializable
data object Empty : AddToPortfolioRoutes
@Serializable
data object PortfolioSelector : AddToPortfolioRoutes
@Serializable
data class NetworkSelector(
val selectedPortfolio: SelectedPortfolio,
) : AddToPortfolioRoutes
@Serializable
data object AddToken : AddToPortfolioRoutes
@Serializable
data object TokenActions : AddToPortfolioRoutes
}

View file

@ -1,133 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.tangem.common.ui.addtoken.AddTokenUM
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.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.models.account.Account
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.api.SelectedNetwork
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent
import com.tangem.features.markets.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class AddTokenModel @Inject constructor(
paramsContainer: ParamsContainer,
private val uiBuilder: AddTokenUiBuilder,
private val messageSender: UiMessageSender,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val checkCurrencyUnsupportedDelegate: CheckCurrencyUnsupportedDelegate,
) : Model() {
private val params = paramsContainer.require<AddTokenComponent.Params>()
private val analyticsEventBuilder = params.eventBuilder
private val addTokenJob = JobHolder()
val uiState: StateFlow<AddTokenUM?>
field = MutableStateFlow(value = null)
init {
combine(
flow = params.selectedNetwork.distinctUntilChanged(),
flow2 = params.selectedPortfolio.distinctUntilChanged(),
transform = { selectedNetwork, selectedPortfolio ->
addTokenJob.join()
val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio)
uiBuilder.updateContent(
selectedPortfolio = selectedPortfolio,
selectedNetwork = selectedNetwork,
isTangemIconVisible = isTangemIconVisible,
onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) },
)
},
)
.onEach { newUI -> uiState.value = newUI }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun onAddClick(selectedNetwork: SelectedNetwork, selectedPortfolio: SelectedPortfolio) =
modelScope.launch(dispatchers.default) {
val um = uiState.value ?: return@launch
val cryptoCurrency = selectedNetwork.cryptoCurrency
val account = selectedPortfolio.account.account.account
val accountId = account.accountId
val isMainNetwork = selectedNetwork.selectedNetwork.contractAddress == null
val unsupportedCurrency = checkCurrencyUnsupportedDelegate.checkCurrencyUnsupportedState(
userWalletId = accountId.userWalletId,
rawNetworkId = selectedNetwork.selectedNetwork.networkId,
isMainNetwork = isMainNetwork,
)
if (unsupportedCurrency != null) return@launch
uiState.value = um.toggleProgress(true)
val blockchainNames = listOf(selectedNetwork.selectedNetwork)
.mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name }
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames))
manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency)
.onLeft { error ->
processError(error = error)
uiState.value = um.toggleProgress(false)
return@launch
}
val status = getAccountCurrencyStatusUseCase(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
).firstOrNull()
if (status == null) {
processError(error = null)
} else {
when (account) {
is Account.CryptoPortfolio -> if (!account.isMainAccount) {
analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount())
}
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
params.callbacks.onTokenAdded(status.status)
}
uiState.value = um.toggleProgress(false)
}
private suspend fun needColdWalletInteraction(
selectedNetwork: SelectedNetwork,
selectedPortfolio: SelectedPortfolio,
): Boolean = coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = selectedPortfolio.userWallet.walletId,
networksWithDerivationPath = mapOf(
selectedNetwork.selectedNetwork.networkId to selectedNetwork.cryptoCurrency.network.derivationPath.value,
),
)
private fun processError(error: Throwable?) {
val message = error?.message?.let { stringReference(it) }
?: resourceReference(R.string.common_something_went_wrong)
messageSender.send(ToastMessage(message = message))
}
}

View file

@ -1,107 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.PortfolioSelectUM
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.addtoken.AddTokenUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.iconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.AccountStatus.*
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.api.SelectedNetwork
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent
import javax.inject.Inject
@ModelScoped
internal class AddTokenUiBuilder @Inject constructor(
paramsContainer: ParamsContainer,
) {
private val params = paramsContainer.require<AddTokenComponent.Params>()
private fun createNetwork(selectedNetwork: SelectedNetwork): AddTokenUM.Network {
return AddTokenUM.Network(
icon = selectedNetwork.cryptoCurrency.network.iconResId,
name = stringReference(selectedNetwork.cryptoCurrency.network.name),
editable = selectedNetwork.hasMoreNetworksAvailable,
onClick = { params.callbacks.onChangeNetworkClick() },
)
}
private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM {
val accountIcon: AccountIconUM?
val portfolioName: TextReference
when (selectedPortfolio.isAccountMode) {
false -> {
accountIcon = null
portfolioName = stringReference(selectedPortfolio.userWallet.name)
}
true -> {
val accountStatus = selectedPortfolio.account.account
portfolioName = accountStatus.account.accountName.toUM().value
accountIcon = when (accountStatus) {
is CryptoPortfolio -> CryptoPortfolioIconConverter.convert(accountStatus.account.icon)
is Payment -> AccountIconUM.Payment
}
}
}
return PortfolioSelectUM(
icon = accountIcon,
name = portfolioName,
isAccountMode = selectedPortfolio.isAccountMode,
isMultiChoice = selectedPortfolio.hasMorePortfoliosAvailable,
onClick = { params.callbacks.onChangePortfolioClick() },
)
}
fun updateContent(
selectedPortfolio: SelectedPortfolio,
selectedNetwork: SelectedNetwork,
isTangemIconVisible: Boolean,
onConfirmClick: () -> Unit,
): AddTokenUM {
// its may happens when change portfolio after selected both params in line navigation
val isAvailableNetwork = selectedPortfolio.account.availableToAddNetworks
.any { selectedNetwork.selectedNetwork.networkId == it.networkId }
val button = AddTokenUM.Button(
isEnabled = isAvailableNetwork,
showProgress = false,
isTangemIconVisible = isTangemIconVisible,
text = resourceReference(R.string.common_add),
onConfirmClick = onConfirmClick,
)
val networkUM = createNetwork(selectedNetwork)
val portfolioUM = createPortfolio(selectedPortfolio)
val currency = selectedNetwork.cryptoCurrency
val tokenToAdd = TokenItemState.Content(
id = currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(currency),
titleState = TokenItemState.TitleState.Content(stringReference(currency.name)),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""),
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return AddTokenUM(
tokenToAdd = tokenToAdd,
network = networkUM,
portfolio = portfolioUM,
button = button,
)
}
companion object {
fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy(
button = this.button.copy(showProgress = showProgress),
)
}
}

View file

@ -1,77 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.model
import arrow.core.getOrElse
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.markets.impl.R
import timber.log.Timber
import javax.inject.Inject
class CheckCurrencyUnsupportedDelegate @Inject constructor(
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val messageSender: UiMessageSender,
) {
suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
rawNetworkId: String,
isMainNetwork: Boolean,
): CurrencyUnsupportedState? {
val result = checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
networkId = rawNetworkId,
isMainNetwork = isMainNetwork,
).getOrElse { throwable ->
Timber.e(
throwable,
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Network ID: $rawNetworkId
|- Is main network: $isMainNetwork
""".trimIndent(),
)
val message = SnackbarMessage(
message = throwable.localizedMessage?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
null
}
if (result != null) {
showUnsupportedWarning(result)
}
return result
}
private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) {
val message = DialogMessage(
message = when (unsupportedState) {
is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_blockchain_by_card_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},
)
messageSender.send(message)
}
}

View file

@ -1,60 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.model
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.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.portfolio.add.impl.ChooseNetworkComponent
import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM
import com.tangem.features.markets.portfolio.impl.model.BlockchainRowUMConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class ChooseNetworkModel @Inject constructor(
paramsContainer: ParamsContainer,
private val checkCurrencyUnsupportedDelegate: CheckCurrencyUnsupportedDelegate,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<ChooseNetworkComponent.Params>()
val uiState: StateFlow<ChooseNetworkUM> = MutableStateFlow(buildUI())
private fun buildUI(): ChooseNetworkUM {
val allAvailable = params.selectedPortfolio.account.availableNetworks
val alreadyAdded = allAvailable
.subtract(params.selectedPortfolio.account.availableToAddNetworks)
val converter = BlockchainRowUMConverter(
alreadyAddedNetworks = alreadyAdded.mapTo(mutableSetOf()) { it.networkId },
)
val allAvailableNetworks = allAvailable.map { it to true }
return ChooseNetworkUM(
networks = converter.convertList(allAvailableNetworks).toPersistentList(),
onNetworkClick = onNetworkClick@{ row ->
val network = allAvailable
.find { it.networkId == row.id }
?: return@onNetworkClick
checkNetwork(row, network)
},
)
}
private fun checkNetwork(row: BlockchainRowUM, network: TokenMarketInfo.Network) = modelScope.launch {
val selectedWalletId = params.selectedPortfolio.userWallet.walletId
val unsupportedState = checkCurrencyUnsupportedDelegate.checkCurrencyUnsupportedState(
userWalletId = selectedWalletId,
rawNetworkId = row.id,
isMainNetwork = row.isMainNetwork,
)
if (unsupportedState == null) {
params.callbacks.onNetworkSelected(network)
}
}
}

View file

@ -1,76 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
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.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.markets.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.markets.portfolio.impl.model.TokenActionsHandler
import com.tangem.features.markets.portfolio.impl.model.TokenActionsHandler.HandledQuickAction
import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class TokenActionsModel @Inject constructor(
paramsContainer: ParamsContainer,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
tokenActionsIntentsFactory: TokenActionsHandler.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val uiBuilder: TokenActionsUiBuilder,
private val analyticsEventHandler: AnalyticsEventHandler,
private val receiveAddressesFactory: ReceiveAddressesFactory,
) : Model() {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
private val analyticsEventBuilder get() = params.eventBuilder
private val currentAppCurrency = getSelectedAppCurrencyUseCase.invokeOrDefault()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val tokenActionsHandler: TokenActionsHandler =
tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) },
)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
val uiState: StateFlow<TokenActionsUM?> = params.data
.mapLatest { uiBuilder.build(it, tokenActionsHandler) }
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
private fun handledQuickAction(handledAction: HandledQuickAction) {
val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action)
analyticsEventHandler.send(event)
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (!isReceive) return
modelScope.launch {
val tokenConfig = receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
) ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
}
}
}

View file

@ -1,44 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.markets.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.model.PortfolioTokenUMConverter
import com.tangem.features.markets.portfolio.impl.model.TokenActionsHandler
import javax.inject.Inject
@ModelScoped
internal class TokenActionsUiBuilder @Inject constructor(
paramsContainer: ParamsContainer,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM {
val status = data.status
val tokenUM = TokenItemState.Content(
id = status.currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(status.currency),
titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)),
fiatAmountState = null,
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return TokenActionsUM(
token = tokenUM,
onLaterClick = {
analyticsEventHandler.send(params.eventBuilder.getTokenLater())
params.callbacks.onLaterClick()
},
quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler),
)
}
}

View file

@ -1,110 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.rows.BlockchainRow
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
private const val DISABLED_ALPHA = 0.4f
@Composable
internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.action),
) {
state.networks.fastForEachIndexed { index, model ->
key(model.id) {
BlockchainRow(
model = model,
itemPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing14,
),
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }),
) {
if (!model.isEnabled) {
Label(
modifier = Modifier.alpha(DISABLED_ALPHA),
state = LabelUM(
text = resourceReference(R.string.common_added),
style = LabelStyle.REGULAR,
),
)
}
}
}
}
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) {
TangemThemePreview {
ChooseNetworkContent(
state = content,
)
}
}
internal class ChooseNetworkContentProvider : PreviewParameterProvider<ChooseNetworkUM> {
private val blockchainRow = BlockchainRowUM(
id = UUID.randomUUID().toString(),
name = "Etherium 3",
type = "TEST",
iconResId = R.drawable.img_eth_22,
isMainNetwork = false,
isSelected = true,
isEnabled = true,
)
override val values: Sequence<ChooseNetworkUM>
get() = sequenceOf(
ChooseNetworkUM(
onNetworkClick = {},
networks = persistentListOf(
blockchainRow.copy(
type = "MAIN",
isMainNetwork = true,
),
blockchainRow.copy(
iconResId = R.drawable.ic_bsc_16,
isEnabled = false,
),
blockchainRow.copy(iconResId = R.drawable.img_polygon_22),
blockchainRow.copy(iconResId = R.drawable.img_optimism_22),
),
),
)
}

View file

@ -1,73 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.ui
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager.State
import com.tangem.features.markets.portfolio.add.impl.converter.AvailableToAddDataConverter
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
internal class DefaultAddToPortfolioManager @AssistedInject constructor(
private val availableToAddDataConverter: AvailableToAddDataConverter,
@Assisted override val token: TokenMarketParams,
@Assisted override val analyticsParams: MarketsPortfolioComponent.AnalyticsParams?,
@Assisted val scope: CoroutineScope,
dispatchers: CoroutineDispatcherProvider,
portfolioFetcherFactory: PortfolioFetcher.Factory,
) : AddToPortfolioManager {
private val _allAvailableNetworks = MutableSharedFlow<List<TokenMarketInfo.Network>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val allAvailableNetworks: Flow<List<TokenMarketInfo.Network>> = _allAvailableNetworks.asSharedFlow()
override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true),
scope = scope,
)
override val state: StateFlow<State> =
combine(
flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(),
flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(),
) { balances, availableNetworks ->
val data = availableToAddDataConverter.convert(
balances = balances,
availableNetworks = availableNetworks,
marketParams = token,
)
if (data.isAvailableToAdd) {
State.AvailableToAdd(data)
} else {
State.NothingToAdd
}
}
.flowOn(dispatchers.default)
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = State.Init,
)
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
_allAvailableNetworks.tryEmit(networks)
}
@AssistedFactory
interface Factory : AddToPortfolioManager.Factory {
override fun create(
scope: CoroutineScope,
token: TokenMarketParams,
analyticsParams: MarketsPortfolioComponent.AnalyticsParams?,
): DefaultAddToPortfolioManager
}
}

View file

@ -1,204 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.badge.drawBadge
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
@Composable
internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
) {
TokenItem(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action),
state = state.token,
isBalanceHidden = false,
)
SpacerH(TangemTheme.dimens.spacing14)
Column(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.action),
) {
state.quickActions.actions.fastForEach { action ->
key(action.title) {
ActionRow(
state = action,
onClick = { state.quickActions.onQuickActionClick(action) },
onLongClick = { state.quickActions.onQuickActionLongClick(action) },
)
}
}
}
SpacerH16()
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_later),
onClick = state.onLaterClick,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ActionRow(
state: QuickActionUM,
onClick: () -> Unit,
onLongClick: (() -> Unit),
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
val onLongClickInternal = {
hapticManager.perform(TangemHapticEffect.View.LongPress)
onLongClick()
}
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable },
onClick = {
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
onClick()
},
)
.padding(horizontal = TangemTheme.dimens.spacing12, vertical = TangemTheme.dimens.spacing15),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
val containerColor = TangemTheme.colors.background.action
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = CircleShape,
)
.size(36.dp)
.drawWithContent {
drawContent()
if (state is QuickActionUM.Exchange && state.shouldShowBadge) {
drawBadge(containerColor = containerColor, offset = 4.dp)
}
},
) {
Icon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size16),
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = state.description.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(TokenActionsContentPreviewProvider::class) state: TokenActionsUM) {
TangemThemePreview {
TokenActionsContent(
state = state,
)
}
}
private class TokenActionsContentPreviewProvider : PreviewParameterProvider<TokenActionsUM> {
private val tokenState
get() = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(
text = stringReference(value = "Tether"),
),
fiatAmountState = null,
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")),
onItemClick = {},
onItemLongClick = {},
)
override val values: Sequence<TokenActionsUM>
get() = sequenceOf(
TokenActionsUM(
quickActions = PortfolioTokenUM.QuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),
QuickActionUM.Receive,
),
onQuickActionClick = {},
onQuickActionLongClick = {},
),
token = tokenState,
onLaterClick = {},
),
)
}

View file

@ -1,9 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.ui.state
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import kotlinx.collections.immutable.ImmutableList
data class ChooseNetworkUM(
val networks: ImmutableList<BlockchainRowUM>,
val onNetworkClick: (BlockchainRowUM) -> Unit,
)

View file

@ -1,10 +0,0 @@
package com.tangem.features.markets.portfolio.add.impl.ui.state
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM
internal data class TokenActionsUM(
val token: TokenItemState,
val quickActions: PortfolioTokenUM.QuickActions,
val onLaterClick: () -> Unit,
)

View file

@ -1,29 +0,0 @@
package com.tangem.features.markets.portfolio.api
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import kotlinx.serialization.Serializable
@Stable
interface MarketsPortfolioComponent : ComposableContentComponent {
@Serializable
data class Params(
val token: TokenMarketParams,
val analyticsParams: AnalyticsParams?,
)
@Serializable
data class AnalyticsParams(
val source: String,
)
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>)
fun setNoNetworksAvailable()
interface Factory : ComponentFactory<Params, MarketsPortfolioComponent>
}

View file

@ -1,90 +0,0 @@
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.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
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.decompose.ComposableBottomSheetComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioModel
import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioRoute
import com.tangem.features.markets.portfolio.impl.ui.MyPortfolio
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Stable
internal class DefaultMarketsPortfolioComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: MarketsPortfolioComponent.Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
) : AppComponentContext by context, MarketsPortfolioComponent {
private val model: MarketsPortfolioModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = MarketsPortfolioRoute.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
model.setTokenNetworks(networks)
}
override fun setNoNetworksAvailable() {
model.setNoNetworksAvailable()
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
MyPortfolio(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: MarketsPortfolioRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
MarketsPortfolioRoute.AddToPortfolio -> addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = requireNotNull(model.newAddToPortfolioManager) {
"newAddToPortfolioManager must be initialized"
},
callback = model.addToPortfolioCallback,
),
)
is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config.config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory
interface Factory : MarketsPortfolioComponent.Factory {
override fun create(
context: AppComponentContext,
params: MarketsPortfolioComponent.Params,
): DefaultMarketsPortfolioComponent
}
}

View file

@ -1,74 +0,0 @@
package com.tangem.features.markets.portfolio.impl.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
internal class PortfolioAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) {
data class EventBuilder(
val token: TokenMarketParams,
val source: String?,
) {
fun addToPortfolioClicked() = PortfolioAnalyticsEvent(
event = "Button - Add To Portfolio",
params = mapOf(
"Token" to token.symbol,
),
)
fun popupToChooseAccount() = PortfolioAnalyticsEvent(
event = "Choose Account Opened",
)
fun addToNotMainAccount() = PortfolioAnalyticsEvent(
event = "Button - Add To Account",
)
fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(event = "Wallet Selected")
fun addToPortfolioContinue(blockchainNames: List<String>) = PortfolioAnalyticsEvent(
event = "Token Network Selected",
params = mapOf(
"Count" to blockchainNames.size.toString(),
"Token" to token.symbol,
"blockchain" to blockchainNames.joinToString(separator = ", "),
),
)
fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) =
PortfolioAnalyticsEvent(
event = when (actionUM) {
TokenActionsBSContentUM.Action.Buy -> "Button - Buy"
TokenActionsBSContentUM.Action.Receive -> "Button - Receive"
TokenActionsBSContentUM.Action.Exchange -> "Button - Swap"
TokenActionsBSContentUM.Action.Stake -> "Button - Stake"
TokenActionsBSContentUM.Action.YieldMode -> "Button - Yield Mode"
else -> "error"
},
params = buildMap {
put("Token", token.symbol)
if (source != null) put("Source", source)
put("blockchain", blockchainName)
},
)
fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent(
event = when (actionUM) {
TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy"
TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive"
TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange"
TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake"
else -> "error"
},
)
fun getTokenLater() = PortfolioAnalyticsEvent(
event = "Popup Get token - Button Later",
)
}
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.markets.portfolio.impl.di
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.markets.portfolio.impl.DefaultMarketsPortfolioComponent
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 ComponentModule {
@Binds
@Singleton
fun bindMarketsPortfolioComponent(
factory: DefaultMarketsPortfolioComponent.Factory,
): MarketsPortfolioComponent.Factory
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.markets.portfolio.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(MarketsPortfolioModel::class)
fun provideMarketsPortfolioModel(model: MarketsPortfolioModel): Model
}

View file

@ -1,33 +0,0 @@
package com.tangem.features.markets.portfolio.impl.loader
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.TokenActionsState
/**
* Portfolio data. Combined data from all flows that required to setup portfolio
*
* @property walletsWithCurrencies wallets with crypto currency statuses
* @property appCurrency app currency
* @property isBalanceHidden flag that indicates if balance should be hidden
* @property walletsWithBalance wallets with total balance
*
[REDACTED_AUTHOR]
*/
internal data class PortfolioData(
val walletsWithCurrencies: Map<UserWallet, List<CryptoCurrencyData>>,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val walletsWithBalance: Map<UserWalletId, Lce<TokenListError, TotalFiatBalance>>,
) {
data class CryptoCurrencyData(
val userWallet: UserWallet,
val status: CryptoCurrencyStatus,
val actions: List<TokenActionsState.ActionState>,
)
}

View file

@ -1,135 +0,0 @@
package com.tangem.features.markets.portfolio.impl.loader
import arrow.core.getOrElse
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetAllWalletsCryptoCurrencyStatusesUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase
import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import javax.inject.Inject
/**
* Loader of portfolio data
*
* @property getAllWalletsCryptoCurrencyStatusesUseCase use case for getting all wallets crypto currency statuses
* @property getSelectedAppCurrencyUseCase use case for getting selected app currency
* @property getBalanceHidingSettingsUseCase use case for getting balance hiding settings
* @property getWalletTotalBalanceUseCase use case for getting wallet total balance
*
[REDACTED_AUTHOR]
*/
internal class PortfolioDataLoader @Inject constructor(
private val getAllWalletsCryptoCurrencyStatusesUseCase: GetAllWalletsCryptoCurrencyStatusesUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase,
private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase,
) {
/** Load data by [currencyRawId] */
@OptIn(ExperimentalCoroutinesApi::class)
fun load(currencyRawId: CryptoCurrency.RawID): Flow<PortfolioData> {
return combine(
flow = getAllWalletsCryptoCurrenciesData(currencyRawId = currencyRawId),
flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { walletsWithCurrencies, appCurrency, isBalanceHidden ->
PortfolioData(
walletsWithCurrencies = walletsWithCurrencies,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
walletsWithBalance = emptyMap(),
)
}
// setup balances for wallets from walletsWithCurrencyStatuses
.flatMapLatest { portfolioData ->
getWalletsWithTotalBalanceFlow(
ids = portfolioData.walletsWithCurrencies.keys.map(UserWallet::walletId),
)
.map { portfolioData.copy(walletsWithBalance = it) }
.onEmpty { emit(portfolioData) }
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun getAllWalletsCryptoCurrenciesData(
currencyRawId: CryptoCurrency.RawID,
): Flow<Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>> {
return getAllWalletsCryptoCurrencyStatusesUseCase(currencyRawId)
.distinctUntilChanged()
.map { walletsWithMaybeStatuses ->
walletsWithMaybeStatuses.mapValues { entry ->
entry.value.mapNotNull { it.getOrNull() }
}
}
.flatMapLatest { walletsWithStatuses ->
val actionsFlows = walletsWithStatuses.flatMap { (wallet, statuses) ->
statuses.map { status ->
val yieldSupplyAvailability = yieldSupplyGetAvailabilityUseCase(status.currency).getOrElse {
YieldSupplyAvailability.Unavailable
}
getCryptoCurrencyActionsUseCase(wallet, status, yieldSupplyAvailability)
.map { actionStates ->
PortfolioData.CryptoCurrencyData(
userWallet = wallet,
status = status,
actions = actionStates.states,
)
}
}
}
combine(actionsFlows) { actions ->
walletsWithStatuses.mapValues { entry ->
entry.value.mapNotNull { status ->
actions.firstOrNull { data ->
data.userWallet == entry.key && data.status == status
}
}
}
}.onEmpty {
emit(
walletsWithStatuses.mapValues { (wallet, statuses) ->
statuses.map { status ->
PortfolioData.CryptoCurrencyData(
userWallet = wallet,
status = status,
actions = emptyList(),
)
}
},
)
}
}.onEmpty {
emit(emptyMap())
}
.distinctUntilChanged()
}
private fun getWalletsWithTotalBalanceFlow(
ids: List<UserWalletId>,
): Flow<Map<UserWalletId, Lce<TokenListError, TotalFiatBalance>>> {
return combine(
flows = ids
.map { userWalletId ->
getWalletTotalBalanceUseCase(userWalletId)
.map { userWalletId to it }
.distinctUntilChanged()
},
transform = { it.toMap() },
)
.distinctUntilChanged()
.onEmpty { ids.associateWith { Lce.Loading<TotalFiatBalance>(partialContent = null) } }
}
}

View file

@ -1,161 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.ui.state.AddToPortfolioBSContentUM
import com.tangem.features.markets.portfolio.impl.ui.state.WalletSelectorBSContentUM
import kotlinx.collections.immutable.toImmutableList
/**
* Factory to create AddToPortfolio bottom sheet content [TangemBottomSheetConfig]
*
* @property token token params
* @property onAddToPortfolioVisibilityChange callback is invoked when add to portfolio visibility is changed
* @property onWalletSelectorVisibilityChange callback is invoked when wallet selector visibility is changed
* @property onNetworkSwitchClick callback is invoked when network switch is clicked
* @property onAnotherWalletSelect callback is invoked when wallet is selected
* @property onContinueClick callback is invoked when continue button is clicked
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class AddToPortfolioBSContentUMFactory(
private val addToPortfolioManager: AddToPortfolioManager,
private val token: TokenMarketParams,
private val onAddToPortfolioVisibilityChange: (Boolean) -> Unit,
private val onWalletSelectorVisibilityChange: (Boolean) -> Unit,
private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit,
private val onAnotherWalletSelect: (UserWalletId) -> Unit,
private val onContinueClick: (selectedWalletId: UserWalletId, addedNetworks: Set<TokenMarketInfo.Network>) -> Unit,
) {
/**
* Create [TangemBottomSheetConfig]
*
* @param portfolioData portfolio data
* @param portfolioUIData portfolio bottom sheet visibility model
* @param selectedWallet selected wallet
* @param alreadyAddedNetworks already added networks
*/
@Suppress("LongParameterList")
fun create(
currentState: TangemBottomSheetConfig?,
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
selectedWallet: UserWallet?,
alreadyAddedNetworks: Set<String>?,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
return (currentState ?: TangemBottomSheetConfig.Empty).copy(
isShown = portfolioUIData.portfolioBSVisibilityModel.isAddToPortfolioBSVisible,
onDismissRequest = { onAddToPortfolioVisibilityChange(false) },
content = if (selectedWallet != null && alreadyAddedNetworks != null) {
AddToPortfolioBSContentUM(
selectedWallet = selectedWallet.toSelectedUserWalletItemUM(
portfolioData = portfolioData,
balance = portfolioData.walletsWithBalance[selectedWallet.walletId]?.getOrNull(),
artwork = artworks[selectedWallet.walletId],
),
selectNetworkUM = SelectNetworkUMConverter(
networksWithToggle = addToPortfolioManager.associateWithToggle(
userWalletId = selectedWallet.walletId,
alreadyAddedNetworkIds = alreadyAddedNetworks,
addToPortfolioData = portfolioUIData.addToPortfolioData,
),
alreadyAddedNetworks = alreadyAddedNetworks,
onNetworkSwitchClick = onNetworkSwitchClick,
).convert(value = token),
isScanCardNotificationVisible = portfolioUIData.shouldRequireColdWalletInteraction,
isContinueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks(
userWalletId = selectedWallet.walletId,
),
onContinueButtonClick = {
onContinueClick(
selectedWallet.walletId,
portfolioUIData.addToPortfolioData.getAddedNetworks(
userWalletId = selectedWallet.walletId,
alreadyAddedNetworkIds = alreadyAddedNetworks,
),
)
},
walletSelectorConfig = createWalletSelectorBSConfig(
isShow = portfolioUIData.portfolioBSVisibilityModel.isWalletSelectorBSVisible,
portfolioData = portfolioData,
selectedWalletId = selectedWallet.walletId,
artworks = artworks,
),
isWalletBlockVisible = portfolioData.walletsWithCurrencies
.filterKeys(UserWallet::isMultiCurrency).size > 1,
)
} else {
TangemBottomSheetConfigContent.Empty
},
)
}
private fun UserWallet.toSelectedUserWalletItemUM(
artwork: UserWalletItemUM.ImageState? = null,
portfolioData: PortfolioData,
balance: TotalFiatBalance?,
): UserWalletItemUM {
return UserWalletItemUMConverter(
onClick = { onWalletSelectorVisibilityChange(true) },
endIcon = UserWalletItemUM.EndIcon.Arrow,
balance = balance,
artwork = artwork,
appCurrency = portfolioData.appCurrency,
isBalanceHidden = portfolioData.isBalanceHidden,
).convert(value = this)
}
private fun createWalletSelectorBSConfig(
isShow: Boolean,
portfolioData: PortfolioData,
selectedWalletId: UserWalletId,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
return TangemBottomSheetConfig(
isShown = isShow,
onDismissRequest = { onWalletSelectorVisibilityChange(false) },
content = WalletSelectorBSContentUM(
userWallets = portfolioData.walletsWithCurrencies
.filterKeys(UserWallet::isMultiCurrency)
.map { it.key }
.map { userWallet ->
val balance = portfolioData.walletsWithBalance[userWallet.walletId]
UserWalletItemUMConverter(
onClick = { walletId ->
if (walletId != selectedWalletId) {
onAnotherWalletSelect(walletId)
onWalletSelectorVisibilityChange(false)
}
},
appCurrency = portfolioData.appCurrency,
balance = balance?.getOrNull(),
isBalanceHidden = portfolioData.isBalanceHidden,
endIcon = if (userWallet.walletId == selectedWalletId) {
UserWalletItemUM.EndIcon.Checkmark
} else {
UserWalletItemUM.EndIcon.None
},
artwork = artworks[userWallet.walletId],
).convert(userWallet)
}
.toImmutableList(),
onBack = { onWalletSelectorVisibilityChange(false) },
),
)
}
}

View file

@ -1,190 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import timber.log.Timber
import javax.inject.Inject
internal typealias WalletsWithNetworks = Map<UserWalletId, Set<TokenMarketInfo.Network>>
/**
* Manager for tracking changing networks in AddToPortfolio
*
[REDACTED_AUTHOR]
*/
internal class AddToPortfolioManager @Inject constructor(
private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase,
) {
val availableNetworks = MutableStateFlow<Set<TokenMarketInfo.Network>?>(value = null)
private val addedNetworks = MutableStateFlow<WalletsWithNetworks>(value = emptyMap())
private val removedNetworks = MutableStateFlow<WalletsWithNetworks>(value = emptyMap())
/** Get [AddToPortfolioData] as flow */
fun getAddToPortfolioData(): Flow<AddToPortfolioData> {
return combine(
flow = availableNetworks,
flow2 = addedNetworks,
flow3 = removedNetworks,
transform = ::AddToPortfolioData,
)
}
/** Set available networks [networks] */
fun setAvailableNetworks(networks: List<TokenMarketInfo.Network>) {
availableNetworks.value = networks.toSet()
}
/** Add network [networkId] to [userWalletId] */
fun addNetwork(userWalletId: UserWalletId, networkId: String) {
addedNetworks.add(userWalletId, networkId)
removedNetworks.cancelPrevChangeIfExist(userWalletId = userWalletId, networkId = networkId)
}
/** Remove network [networkId] from [userWalletId] */
fun removeNetwork(userWalletId: UserWalletId, networkId: String) {
removedNetworks.add(userWalletId, networkId)
addedNetworks.cancelPrevChangeIfExist(
userWalletId = userWalletId,
networkId = networkId,
)
}
/** Remove all networks by [userWalletId] */
fun removeAllChanges(userWalletId: UserWalletId) {
addedNetworks.update {
it.toMutableMap().apply { remove(userWalletId) }
}
removedNetworks.update {
it.toMutableMap().apply { remove(userWalletId) }
}
}
fun associateWithToggle(
userWalletId: UserWalletId,
alreadyAddedNetworkIds: Set<String>,
addToPortfolioData: AddToPortfolioData,
): Map<TokenMarketInfo.Network, Boolean> {
val filteredNetworks = filterAvailableNetworksForWalletUseCase(
userWalletId = userWalletId,
networks = addToPortfolioData.availableNetworks.orEmpty(),
)
// Use user choice or check already added networks
return filteredNetworks.associateWith { availableNetwork ->
val isAddedByUser = addToPortfolioData.addedNetworks[userWalletId]?.contains(availableNetwork)
if (isAddedByUser == true) return@associateWith true
val isRemovedByUser = addToPortfolioData.removedNetworks[userWalletId]?.contains(availableNetwork)
if (isRemovedByUser == true) return@associateWith false
val isAddedBefore = alreadyAddedNetworkIds.any { it == availableNetwork.networkId }
isAddedBefore
}
}
private fun MutableStateFlow<WalletsWithNetworks>.cancelPrevChangeIfExist(
userWalletId: UserWalletId,
networkId: String,
) {
if (value[userWalletId].orEmpty().any { it.networkId == networkId }) remove(userWalletId, networkId)
}
private fun MutableStateFlow<WalletsWithNetworks>.add(userWalletId: UserWalletId, networkId: String) {
change(userWalletId = userWalletId, networkId = networkId, isAddAction = true)
}
private fun MutableStateFlow<WalletsWithNetworks>.remove(userWalletId: UserWalletId, networkId: String) {
change(userWalletId = userWalletId, networkId = networkId, isAddAction = false)
}
private fun MutableStateFlow<WalletsWithNetworks>.change(
userWalletId: UserWalletId,
networkId: String,
isAddAction: Boolean,
) {
val network = availableNetworks.value.orEmpty().firstOrNull { it.networkId == networkId }
if (network == null) {
Timber.d(
"Network [$networkId] doesn't contain in available networks [%s]",
availableNetworks.value?.joinToString { it.networkId },
)
return
}
update { currentMap ->
currentMap.toMutableMap().apply {
this[userWalletId] = if (isAddAction) {
this[userWalletId].orEmpty() + network
} else {
this[userWalletId].orEmpty() - network
}
}
}
}
/**
* Add to portfolio data
*
* @property availableNetworks available networks that user can add to portfolio
* @property addedNetworks networks that user toggled on, but it might have already been added to the wallet
* @property removedNetworks networks that user toggled off, but it might haven't been added to the wallet
*
* Example for [addedNetworks] and [removedNetworks]. This lists will include new networks when user just
* toggle it. But when we will save user changes, we will check what tokens have already been added or
* haven't been added to the wallet. See [getAddedNetworks] and [getRemovedNetworks]
*/
data class AddToPortfolioData(
val availableNetworks: Set<TokenMarketInfo.Network>?,
val addedNetworks: WalletsWithNetworks,
val removedNetworks: WalletsWithNetworks,
) {
fun isUserAddedNetworks(userWalletId: UserWalletId): Boolean {
return addedNetworks[userWalletId].orEmpty().isNotEmpty()
}
fun isUserChangedNetworks(userWalletId: UserWalletId): Boolean {
return addedNetworks[userWalletId].orEmpty().isNotEmpty() ||
removedNetworks[userWalletId].orEmpty().isNotEmpty()
}
/** Get new networks that user [userWalletId] added using [alreadyAddedNetworkIds] */
fun getAddedNetworks(
userWalletId: UserWalletId,
alreadyAddedNetworkIds: Set<String>,
): Set<TokenMarketInfo.Network> {
val addedNetworksByUser = addedNetworks[userWalletId].orEmpty()
return addedNetworksByUser.map { it.networkId }
.minus(alreadyAddedNetworkIds)
.mapNotNull { networkId -> addedNetworksByUser.firstOrNull { it.networkId == networkId } }
.toSet()
}
/** Get networks that user [userWalletId] removed using [alreadyAddedNetworkIds] */
fun getRemovedNetworks(
userWalletId: UserWalletId,
alreadyAddedNetworkIds: Set<String>,
): Set<TokenMarketInfo.Network> {
val removedNetworksByUser = removedNetworks[userWalletId].orEmpty()
return alreadyAddedNetworkIds
.minus(removedNetworksByUser.map { it.networkId }.toSet())
.mapNotNull { networkId -> removedNetworksByUser.firstOrNull { it.networkId == networkId } }
.toSet()
}
}
}

View file

@ -1,66 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.getGreyedOutIconRes
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.converter.Converter
/**
* Converter from [TokenMarketInfo.Network] to [BlockchainRowUM]
*
* @property alreadyAddedNetworks set of already added networks
*
[REDACTED_AUTHOR]
*/
internal class BlockchainRowUMConverter(
private val alreadyAddedNetworks: Set<String>,
) : Converter<Pair<TokenMarketInfo.Network, Boolean>, BlockchainRowUM> {
override fun convert(value: Pair<TokenMarketInfo.Network, Boolean>): BlockchainRowUM {
val (network, isSelected) = value
val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId)
?: error("Can't find blockchain info for ${network.networkId}")
val isMainNetwork = network.contractAddress == null
val isEnabled = !alreadyAddedNetworks.contains(network.networkId)
return BlockchainRowUM(
id = network.networkId,
name = blockchainInfo.name,
type = getNetworkType(network, blockchainInfo),
iconResId = if (isEnabled) {
if (isSelected) {
getActiveIconRes(blockchainInfo.blockchainId)
} else {
getGreyedOutIconRes(blockchainInfo.blockchainId)
}
} else {
getGreyedOutIconRes(blockchainInfo.blockchainId)
},
isMainNetwork = isMainNetwork,
isSelected = isSelected,
isEnabled = isEnabled,
)
}
private fun getNetworkType(
network: TokenMarketInfo.Network,
blockchainInfo: BlockchainUtils.BlockchainInfo,
): String {
val isMainNetwork = network.contractAddress == null
return when {
BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME
isMainNetwork -> MAIN_NETWORK_TYPE_NAME
else -> blockchainInfo.protocolName
}
}
private companion object {
const val MAIN_NETWORK_TYPE_NAME = "MAIN"
const val MAIN_NETWORK_L2_TYPE_NAME = "MAIN L2"
}
}

View file

@ -1,426 +0,0 @@
package com.tangem.features.markets.portfolio.impl.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.common.ui.userwallet.state.UserWalletItemUM
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.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.markets.SaveMarketTokensUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader
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.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.wallet.utils.UserWalletImageFetcher
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.operations.attestation.ArtworkSize
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager as NewAddToPortfolioManager
@Suppress("LongParameterList", "LargeClass")
@Stable
@ModelScoped
internal class MarketsPortfolioModel @Inject constructor(
paramsContainer: ParamsContainer,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
tokenActionsIntentsFactory: TokenActionsHandler.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val messageSender: UiMessageSender,
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
private val portfolioDataLoader: PortfolioDataLoader,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
private val saveMarketTokensUseCase: SaveMarketTokensUseCase,
private val addToPortfolioManager: AddToPortfolioManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletImageFetcher: UserWalletImageFetcher,
private val receiveAddressesFactory: ReceiveAddressesFactory,
accountsFeatureToggles: AccountsFeatureToggles,
newAddToPortfolioManagerFactory: NewAddToPortfolioManager.Factory,
newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory,
) : Model() {
private val _state: MutableStateFlow<MyPortfolioUM> = MutableStateFlow(value = MyPortfolioUM.Loading)
val state: StateFlow<MyPortfolioUM> get() = _state
private val params = paramsContainer.require<MarketsPortfolioComponent.Params>()
private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder(
token = params.token,
source = params.analyticsParams?.source,
)
val newAddToPortfolioManager: NewAddToPortfolioManager?
val newMarketsPortfolioDelegate: NewMarketsPortfolioDelegate?
/** Multi-wallet [UserWalletId] that user uses to add new tokens in AddToPortfolio bottom sheet */
private val selectedMultiWalletIdFlow = MutableStateFlow<UserWalletId?>(value = null)
private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel())
val bottomSheetNavigation: SlotNavigation<MarketsPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
}
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val tokenActionsHandler = tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
onHandleQuickAction = { handledAction ->
val currencyNetwork = handledAction.cryptoCurrencyData.status.currency.network
analyticsEventHandler.send(
analyticsEventBuilder.quickActionClick(
actionUM = handledAction.action,
blockchainName = currencyNetwork.name,
),
)
configureReceiveAddresses(handledAction)
},
)
private val factory = MyPortfolioUMFactory(
onAddClick = {
onAddToPortfolioBSVisibilityChange(isShow = true)
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.addToPortfolioClicked(),
)
},
addToPortfolioBSContentUMFactory = AddToPortfolioBSContentUMFactory(
addToPortfolioManager = addToPortfolioManager,
token = params.token,
onAddToPortfolioVisibilityChange = ::onAddToPortfolioBSVisibilityChange,
onWalletSelectorVisibilityChange = ::onWalletSelectorVisibilityChange,
onNetworkSwitchClick = ::onNetworkSwitchClick,
onAnotherWalletSelect = { walletId ->
onWalletSelect(walletId)
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.addToPortfolioWalletChanged(),
)
},
onContinueClick = { selectedWalletId, addedNetworks ->
onContinueClick(selectedWalletId, addedNetworks)
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.addToPortfolioContinue(
blockchainNames = addedNetworks.mapNotNull {
BlockchainUtils.getNetworkInfo(it.networkId)?.name
},
),
)
},
),
currentState = Provider { _state.value },
tokenActionsHandler = tokenActionsHandler,
updateTokens = { updateBlock ->
updateTokensState { state ->
state.copy(tokens = updateBlock(state.tokens))
}
},
)
init {
if (accountsFeatureToggles.isFeatureEnabled) {
newAddToPortfolioManager = newAddToPortfolioManagerFactory
.create(
modelScope,
params.token,
params.analyticsParams,
)
newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create(
scope = modelScope,
token = params.token,
tokenActionsHandler = tokenActionsHandler,
buttonState = newAddToPortfolioManager.state.map { managerState ->
when (managerState) {
is NewAddToPortfolioManager.State.AvailableToAdd -> AddButtonState.Available
NewAddToPortfolioManager.State.Init -> AddButtonState.Loading
NewAddToPortfolioManager.State.NothingToAdd -> AddButtonState.Unavailable
}
},
onAddClick = {
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioClicked())
bottomSheetNavigation.activate(MarketsPortfolioRoute.AddToPortfolio)
},
)
newMarketsPortfolioDelegate.combineData()
.onEach { _state.value = it }
.flowOn(dispatchers.default)
.launchIn(modelScope)
} else {
newAddToPortfolioManager = null
newMarketsPortfolioDelegate = null
// Subscribe on selected wallet flow to support actual selected wallet
subscribeOnSelectedMultiWalletUpdates()
subscribeOnStateUpdates()
}
}
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
addToPortfolioManager.setAvailableNetworks(networks)
newAddToPortfolioManager?.setTokenNetworks(networks)
newMarketsPortfolioDelegate?.setTokenNetworks(networks)
}
fun setNoNetworksAvailable() {
addToPortfolioManager.setAvailableNetworks(emptyList())
newAddToPortfolioManager?.setTokenNetworks(emptyList())
newMarketsPortfolioDelegate?.setTokenNetworks(emptyList())
}
private fun subscribeOnSelectedMultiWalletUpdates() {
getSelectedWalletUseCase()
.getOrElse { e ->
Timber.e("Failed to load selected wallet: $e")
error("Failed to load selected wallet")
}
.onEach { wallet ->
selectedMultiWalletIdFlow.value = wallet.takeIf { it.isMultiCurrency }?.walletId
}
.launchIn(modelScope)
}
private fun subscribeOnStateUpdates() {
combine(
flow = loadPortfolioDataWithArtworks(params.token.id),
flow2 = getPortfolioUIDataFlow(),
transform = { pair, portfolioUIData ->
val (portfolioData, artworks) = pair
factory.create(portfolioData, portfolioUIData, artworks)
},
)
.onEach { _state.value = it }
.launchIn(modelScope)
}
private fun loadPortfolioDataWithArtworks(
currencyRawId: CryptoCurrency.RawID,
): Flow<Pair<PortfolioData, Map<UserWalletId, UserWalletItemUM.ImageState>>> {
val wallets = Channel<Set<UserWallet>>()
val portfolioFlow = portfolioDataLoader
.load(currencyRawId)
.onEach { wallets.trySend(it.walletsWithCurrencies.keys) }
val artworksFlow = wallets.receiveAsFlow()
.distinctUntilChanged()
.flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) }
return combine(
flow = portfolioFlow,
flow2 = artworksFlow,
) { portfolioData, artworks -> portfolioData to artworks }
}
private fun getPortfolioUIDataFlow(): Flow<PortfolioUIData> {
return combine(
flow = portfolioBSVisibilityModelFlow,
flow2 = selectedMultiWalletIdFlow,
flow3 = addToPortfolioManager.getAddToPortfolioData(),
transform = { portfolioBSVisibilityModel, selectedWalletId, addToPortfolioData ->
PortfolioUIData(
portfolioBSVisibilityModel = portfolioBSVisibilityModel,
selectedWalletId = selectedWalletId,
addToPortfolioData = addToPortfolioData,
shouldRequireColdWalletInteraction = needColdWalletInteraction(
selectedWalletId,
addToPortfolioData,
),
)
},
)
}
private suspend fun needColdWalletInteraction(
selectedWalletId: UserWalletId?,
addToPortfolioData: AddToPortfolioManager.AddToPortfolioData,
): Boolean {
return if (selectedWalletId != null) {
coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = selectedWalletId,
networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty()
.associate { it.networkId to null },
)
} else {
false
}
}
private fun onNetworkSwitchClick(blockchainRowUM: BlockchainRowUM, isChecked: Boolean) {
val selectedWalletId = selectedMultiWalletIdFlow.value
if (selectedWalletId == null) {
Timber.e("Impossible to switch network when selected wallet is null")
return
}
if (isChecked) {
modelScope.launch {
val unsupportedState = checkCurrencyUnsupportedState(
userWalletId = selectedWalletId,
rawNetworkId = blockchainRowUM.id,
isMainNetwork = blockchainRowUM.isMainNetwork,
)
if (unsupportedState != null) {
showUnsupportedWarning(unsupportedState)
} else {
addToPortfolioManager.addNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id)
}
}
} else {
addToPortfolioManager.removeNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id)
}
}
private suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
rawNetworkId: String,
isMainNetwork: Boolean,
): CurrencyUnsupportedState? {
return checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
networkId = rawNetworkId,
isMainNetwork = isMainNetwork,
).getOrElse { error ->
Timber.e(
error,
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Network ID: $rawNetworkId
|- Is main network: $isMainNetwork
""".trimIndent(),
)
val message = SnackbarMessage(
message = error.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
null
}
}
private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) {
val message = DialogMessage(
message = when (unsupportedState) {
is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},
)
messageSender.send(message)
}
private fun onWalletSelect(userWalletId: UserWalletId) {
selectedMultiWalletIdFlow.update { prevUserWalletId ->
prevUserWalletId?.let(addToPortfolioManager::removeAllChanges)
userWalletId
}
}
private fun onContinueClick(userWalletId: UserWalletId, addedNetworks: Set<TokenMarketInfo.Network>) {
modelScope.launch {
saveMarketTokensUseCase(
userWalletId = userWalletId,
tokenMarketParams = params.token,
addedNetworks = addedNetworks,
removedNetworks = emptySet(),
)
onAddToPortfolioBSVisibilityChange(isShow = false)
addToPortfolioManager.removeAllChanges(userWalletId)
}
}
private fun onAddToPortfolioBSVisibilityChange(isShow: Boolean) {
portfolioBSVisibilityModelFlow.update {
it.copy(isAddToPortfolioBSVisible = isShow, isWalletSelectorBSVisible = false)
}
}
private fun onWalletSelectorVisibilityChange(isShow: Boolean) {
portfolioBSVisibilityModelFlow.update {
it.copy(isAddToPortfolioBSVisible = true, isWalletSelectorBSVisible = isShow)
}
}
private fun updateTokensState(block: (MyPortfolioUM.Tokens) -> MyPortfolioUM) {
_state.update { stateToUpdate ->
val tokensState = stateToUpdate as? MyPortfolioUM.Tokens ?: return@update stateToUpdate
block(tokensState)
}
}
private fun configureReceiveAddresses(quickAction: TokenActionsHandler.HandledQuickAction) {
val isNewReceive = quickAction.action == TokenActionsBSContentUM.Action.Receive
if (isNewReceive) {
modelScope.launch {
val tokenConfig = receiveAddressesFactory.create(
status = quickAction.cryptoCurrencyData.status,
userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId,
) ?: return@launch
bottomSheetNavigation.activate(MarketsPortfolioRoute.TokenReceive(tokenConfig))
}
}
}
}

View file

@ -1,17 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.models.TokenReceiveConfig
import kotlinx.serialization.Serializable
@Serializable
sealed interface MarketsPortfolioRoute : Route {
@Serializable
data object AddToPortfolio : MarketsPortfolioRoute
@Serializable
data class TokenReceive(
val config: TokenReceiveConfig,
) : MarketsPortfolioRoute
}

View file

@ -1,150 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
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.features.markets.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
/**
* Factory for creating [MyPortfolioUM]
*
* @property onAddClick callback when user wants to add new token
* @property addToPortfolioBSContentUMFactory factory for creating add to portfolio bottom sheet content
* @property tokenActionsHandler token actions handler
* @property currentState current state provider
* @property updateTokens callback for updating tokens
*
[REDACTED_AUTHOR]
*/
internal class MyPortfolioUMFactory(
private val onAddClick: () -> Unit,
private val addToPortfolioBSContentUMFactory: AddToPortfolioBSContentUMFactory,
private val tokenActionsHandler: TokenActionsHandler,
private val currentState: Provider<MyPortfolioUM>,
private val updateTokens: ((ImmutableList<PortfolioTokenUM>) -> ImmutableList<PortfolioTokenUM>) -> Unit,
) {
fun create(
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): MyPortfolioUM {
val addToPortfolioData = portfolioUIData.addToPortfolioData
val isOnlyUnavailableNetworks = addToPortfolioData.availableNetworks?.isEmpty() == true
if (isOnlyUnavailableNetworks) return MyPortfolioUM.Unavailable
val walletsWithCurrencies = if (addToPortfolioData.availableNetworks == null) {
portfolioData.walletsWithCurrencies
} else {
portfolioData.walletsWithCurrencies.filterAvailableNetworks(networks = addToPortfolioData.availableNetworks)
}
val isPortfolioEmpty = walletsWithCurrencies.flatMap { it.value }.isEmpty()
if (isPortfolioEmpty) {
val hasMultiWallets = walletsWithCurrencies.filterKeys(UserWallet::isMultiCurrency).isNotEmpty()
return if (hasMultiWallets) {
MyPortfolioUM.AddFirstToken(
addToPortfolioBSConfig = createAddToPortfolioBSConfig(
portfolioData = portfolioData,
portfolioUIData = portfolioUIData,
artworks = artworks,
),
onAddClick = onAddClick,
)
} else {
MyPortfolioUM.UnavailableForWallet
}
}
return TokensPortfolioUMConverter(
appCurrency = portfolioData.appCurrency,
isBalanceHidden = portfolioData.isBalanceHidden,
addButtonState = walletsWithCurrencies.getAddButtonState(
availableNetworks = addToPortfolioData.availableNetworks,
),
bsConfig = createAddToPortfolioBSConfig(
portfolioData = portfolioData,
portfolioUIData = portfolioUIData,
artworks = artworks,
),
onAddClick = onAddClick,
quickActionsIntents = tokenActionsHandler,
currentState = currentState,
updateTokens = updateTokens,
)
.convert(walletsWithCurrencies)
}
private fun createAddToPortfolioBSConfig(
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
val selectedWallet = portfolioData.walletsWithCurrencies.keys
.firstOrNull { it.walletId == portfolioUIData.selectedWalletId }
?: portfolioData.walletsWithCurrencies.keys.firstOrNull { it.isMultiCurrency }
val availableNetworks = portfolioUIData.addToPortfolioData.availableNetworks.orEmpty()
val alreadyAddedNetworks = portfolioData.walletsWithCurrencies
.filterAvailableNetworks(availableNetworks)[selectedWallet]
?.filter { !it.status.currency.isCustom }
?.map { it.status.currency.network.backendId }
?.toSet()
return addToPortfolioBSContentUMFactory.create(
currentState = currentState().addToPortfolioBSConfig,
portfolioData = portfolioData,
portfolioUIData = portfolioUIData,
selectedWallet = selectedWallet,
alreadyAddedNetworks = alreadyAddedNetworks,
artworks = artworks,
)
}
private fun Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>.getAddButtonState(
availableNetworks: Set<TokenMarketInfo.Network>?,
): AddButtonState {
if (availableNetworks == null) return AddButtonState.Loading
val networkIds = availableNetworks.map { it.networkId }
val isAllAvailableNetworksAdded = this
// User can add currencies only in multi-currency wallets
.filterKeys(UserWallet::isMultiCurrency)
.mapValues { entry -> entry.value.map { it.status.currency.network.backendId } }
// Each wallets contains all available networks?
.all { it.value.containsAll(networkIds) }
return if (isAllAvailableNetworksAdded) AddButtonState.Unavailable else AddButtonState.Available
}
/** Filter map values by available networks [networks] */
private fun Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>.filterAvailableNetworks(
networks: Set<TokenMarketInfo.Network>,
): Map<UserWallet, List<PortfolioData.CryptoCurrencyData>> {
return mapValues { entry -> entry.value.filterAvailableNetworks(networks) }
}
/** Filter list of [CryptoCurrencyStatus] by available networks [networks] */
private fun List<PortfolioData.CryptoCurrencyData>.filterAvailableNetworks(
networks: Set<TokenMarketInfo.Network>,
): List<PortfolioData.CryptoCurrencyData> {
val networkIds = networks.map(TokenMarketInfo.Network::networkId)
return mapNotNull { currencyData ->
currencyData.takeIf { networkIds.contains(it.status.currency.network.backendId) }
}
}
}

View file

@ -1,351 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import arrow.core.getOrElse
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
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.markets.TokenMarketParams
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
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.features.markets.portfolio.impl.ui.state.PortfolioHeader
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioListItem
import com.tangem.features.markets.portfolio.impl.ui.state.WalletHeader
import com.tangem.utils.extensions.isZero
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongParameterList")
internal class NewMarketsPortfolioDelegate @AssistedInject constructor(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val allAccountSupplier: MultiAccountStatusListSupplier,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase,
isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
@Assisted private val scope: CoroutineScope,
@Assisted private val token: TokenMarketParams,
@Assisted private val tokenActionsHandler: TokenActionsHandler,
@Assisted private val buttonState: Flow<AddButtonState>,
@Assisted private val onAddClick: () -> Unit,
) {
private val currencyRawId: CryptoCurrency.RawID = token.id
private var expandedHolder: MutableStateFlow<Set<Pair<UserWalletId, CryptoCurrency.ID>>>? = null
private val settingsFlow: Flow<SettingsBox> = combine(
flow = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
flow3 = isAccountsModeEnabledUseCase(),
transform = ::SettingsBox,
).shareIn(
replay = 1,
started = SharingStarted.Eagerly,
scope = scope,
).distinctUntilChanged()
private val availableNetworks = MutableSharedFlow<List<TokenMarketInfo.Network>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
availableNetworks.tryEmit(networks)
}
fun combineData(): Flow<MyPortfolioUM> {
return availableNetworks.transformLatest { availableNetworks ->
when {
availableNetworks.isEmpty() -> emit(MyPortfolioUM.Unavailable)
else -> emitAll(onAvailableNetworksFlow().distinctUntilChanged())
}
}.distinctUntilChanged()
}
private fun onAvailableNetworksFlow(): Flow<MyPortfolioUM> =
portfolioWithThisCurrencyFLow().transformLatest { portfolioWithCurrency ->
when (portfolioWithCurrency.flattenAddedCurrency.isEmpty()) {
false -> emitAll(contentFlow(portfolioWithCurrency).distinctUntilChanged())
true -> when (portfolioWithCurrency.hasMultiWallets) {
true -> emitAll(addFirstTokenFlow())
false -> emit(MyPortfolioUM.UnavailableForWallet)
}
}
}
private fun addFirstTokenFlow(): Flow<MyPortfolioUM> = buttonState.map { state ->
when (state) {
AddButtonState.Loading -> MyPortfolioUM.Loading
AddButtonState.Available -> MyPortfolioUM.AddFirstToken(
onAddClick = onAddClick,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
)
AddButtonState.Unavailable -> MyPortfolioUM.Unavailable
}
}
private fun contentFlow(portfolio: PortfoliosWithThisCurrency): Flow<MyPortfolioUM.Content> {
fun Portfolio.actionsFoAccountCurrencies(): List<Flow<Pair<CryptoCurrency, TokenActionsState>>> =
accountsWithAdded.map { account ->
fun CryptoCurrencyStatus.actionsFlow(): Flow<Pair<CryptoCurrency, TokenActionsState>> = flow {
val yieldSupplyAvailability = yieldSupplyGetAvailabilityUseCase(this@actionsFlow.currency)
.getOrElse { YieldSupplyAvailability.Unavailable }
emitAll(
getCryptoCurrencyActionsUseCase(
accountId = account.accountStatus.account.accountId,
currency = this@actionsFlow.currency,
yieldSupplyAvailability = yieldSupplyAvailability,
).map { actionsState -> actionsState.cryptoCurrencyStatus.currency to actionsState },
)
}
account.addedCurrency.map { it.actionsFlow() }
}.flatten()
val allAddedTokenActions =
portfolio.portfolios.map { portfolioItem -> portfolioItem.actionsFoAccountCurrencies() }.flatten()
return combine(
flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(),
flow2 = buttonState.distinctUntilChanged(),
flow3 = getExpandedHolder(portfolio),
flow4 = settingsFlow.distinctUntilChanged(),
transform = { actions, addButtonState, expanded, settings ->
buildContentState(
portfolio = portfolio,
allActions = actions,
addButtonState = addButtonState,
expanded = expanded,
settings = settings,
)
},
)
}
private fun getExpandedHolder(
portfolio: PortfoliosWithThisCurrency,
): StateFlow<Set<Pair<UserWalletId, CryptoCurrency.ID>>> {
val expandedHolder = this.expandedHolder
if (expandedHolder != null) return expandedHolder
val allAddedCurrency = portfolio.flattenAddedCurrency
val shouldForceExpand = allAddedCurrency.size == 1 &&
allAddedCurrency.first().value.amount?.isZero() == true
val initValue = when {
shouldForceExpand -> {
val currency = allAddedCurrency.first()
// find userWallet than have this single added token
portfolio.portfolios
.find { it.accountsWithAdded.any { account -> account.addedCurrency.isNotEmpty() } }
?.userWallet
?.let { setOf(it.walletId to currency.currency.id) }
.orEmpty()
}
else -> emptySet()
}
return MutableStateFlow(initValue)
.also { this.expandedHolder = it }
}
private fun portfolioWithThisCurrencyFLow(): Flow<PortfoliosWithThisCurrency> =
allAccountSupplier().map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows ->
combine(flows) { portfolios ->
PortfoliosWithThisCurrency(
currencyRawId = currencyRawId,
portfolios = portfolios.toList(),
)
}
}.distinctUntilChanged()
private fun AccountStatusList.addedAccountsFlow(): Flow<Portfolio> =
getUserWalletUseCase.invokeFlow(this.userWalletId).mapNotNull { it.getOrNull() }.map { wallet ->
Portfolio(
userWallet = wallet,
accountStatusList = this,
accountsWithAdded = this.filterByRawID(),
)
}.distinctUntilChanged()
private fun AccountStatusList.filterByRawID(): List<AccountWithAdded> {
fun AccountStatus.filterByRawID(): List<CryptoCurrencyStatus> = when (this) {
is AccountStatus.CryptoPortfolio -> this.tokenList.flattenCurrencies()
.filter { status ->
val currencyId = status.currency.id.rawCurrencyId ?: return@filter false
getTokenIdIfL2Network(currencyId.value) == currencyRawId.value
}
is AccountStatus.Payment -> TODO("[REDACTED_JIRA]")
}
return accountStatuses.map { accountStatus ->
AccountWithAdded(
accountStatus = accountStatus,
addedCurrency = accountStatus.filterByRawID(),
)
}
}
private fun buildContentState(
portfolio: PortfoliosWithThisCurrency,
allActions: Map<CryptoCurrency, TokenActionsState>,
addButtonState: AddButtonState,
expanded: Set<Pair<UserWalletId, CryptoCurrency.ID>>,
settings: SettingsBox,
): MyPortfolioUM.Content {
val appCurrency = settings.appCurrency
val isBalanceHidden = settings.isBalanceHidden
val isAccountMode = settings.isAccountMode
val uiItems: MutableList<PortfolioListItem> = mutableListOf()
fun toggleQuickActions(key: Pair<UserWalletId, CryptoCurrency.ID>) = expandedHolder?.update { expanded ->
val isExpand = expanded.contains(key)
if (isExpand) expanded.minus(key) else expanded.plus(key)
}
val tokenUMConverter = PortfolioTokenUMConverter(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
onTokenItemClick = { },
tokenActionsHandler = tokenActionsHandler,
)
portfolio.portfolios.forEach { portfolioItem ->
if (portfolioItem.flattenAddedCurrency.isEmpty()) return@forEach
val userWallet = portfolioItem.userWallet
if (isAccountMode) {
uiItems.add(portfolioItem.userWallet.toWalletHeader())
} else {
uiItems.add(portfolioItem.userWallet.toWalletPortfolioHeader())
}
portfolioItem.accountsWithAdded.forEach { accountWithAdded ->
if (accountWithAdded.addedCurrency.isEmpty()) return@forEach
if (isAccountMode) {
val account = accountWithAdded.accountStatus.account
uiItems.add(account.toAccountPortfolioHeader())
}
accountWithAdded.addedCurrency.forEach { currencyStatus ->
val actions = allActions[currencyStatus.currency]?.states.orEmpty()
val value = PortfolioData.CryptoCurrencyData(
userWallet = userWallet,
status = currencyStatus,
actions = actions,
)
val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id
val isExpand = expanded.contains(expandedKey)
val tokenItem = tokenUMConverter.convertV2(
onTokenItemClick = { wallet, status ->
toggleQuickActions(wallet.walletId to status.currency.id)
},
value = value,
isQuickActionsShown = isExpand,
)
uiItems.add(tokenItem)
}
}
}
return MyPortfolioUM.Content(
items = uiItems.toImmutableList(),
buttonState = addButtonState,
onAddClick = onAddClick,
)
}
private fun Account.toAccountPortfolioHeader(): PortfolioHeader = PortfolioHeader(
id = this.accountId.value,
state = AccountTitleUM.Account(
prefixText = TextReference.EMPTY,
name = this.accountName.toUM().value,
icon = when (this) {
is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(this.icon)
is Account.Payment -> TODO("[REDACTED_JIRA]")
},
),
)
private fun UserWallet.toWalletPortfolioHeader(): PortfolioHeader = PortfolioHeader(
id = this.walletId.stringValue,
state = AccountTitleUM.Text(
title = stringReference(this.name),
),
)
private fun UserWallet.toWalletHeader(): WalletHeader = WalletHeader(
id = this.walletId.stringValue,
name = stringReference(this.name),
)
@Suppress("LongParameterList")
@AssistedFactory
interface Factory {
fun create(
scope: CoroutineScope,
token: TokenMarketParams,
tokenActionsHandler: TokenActionsHandler,
buttonState: Flow<AddButtonState>,
onAddClick: () -> Unit,
): NewMarketsPortfolioDelegate
}
}
private data class PortfoliosWithThisCurrency(
val currencyRawId: CryptoCurrency.RawID,
val portfolios: List<Portfolio>,
) {
val hasMultiWallets: Boolean = portfolios.any { it.userWallet.isMultiCurrency }
val flattenAddedCurrency: List<CryptoCurrencyStatus> =
portfolios.map { portfolio -> portfolio.flattenAddedCurrency }.flatten()
}
private data class Portfolio(
val userWallet: UserWallet,
val accountStatusList: AccountStatusList,
val accountsWithAdded: List<AccountWithAdded>,
) {
val flattenAddedCurrency: List<CryptoCurrencyStatus> =
accountsWithAdded.map { it.addedCurrency }.flatten()
}
private data class AccountWithAdded(
val addedCurrency: List<CryptoCurrencyStatus>,
val accountStatus: AccountStatus,
)
private data class SettingsBox(
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val isAccountMode: Boolean,
)

View file

@ -1,14 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
/**
* Model for portfolio bottom sheet visibility
*
* @property isAddToPortfolioBSVisible visibility of add to portfolio bottom sheet
* @property isWalletSelectorBSVisible visibility of wallet selector bottom sheet
*
[REDACTED_AUTHOR]
*/
internal data class PortfolioBSVisibilityModel(
val isAddToPortfolioBSVisible: Boolean = false,
val isWalletSelectorBSVisible: Boolean = false,
)

View file

@ -1,126 +0,0 @@
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.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM
import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
/**
* 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,
private val tokenActionsHandler: TokenActionsHandler,
) : Converter<PortfolioData.CryptoCurrencyData, PortfolioTokenUM> {
fun convertV2(
value: PortfolioData.CryptoCurrencyData,
isQuickActionsShown: Boolean,
onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit,
): PortfolioTokenUM {
val tokenItemStateConverter = TokenItemStateConverter(
appCurrency = appCurrency,
onItemClick = { _, status -> onTokenItemClick(value.userWallet, status) },
)
return PortfolioTokenUM(
tokenItemState = tokenItemStateConverter.convert(value = value.status),
walletId = value.userWallet.walletId,
isBalanceHidden = isBalanceHidden,
isQuickActionsShown = isQuickActionsShown,
quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler),
)
}
override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM {
val tokenItemStateConverter = TokenItemStateConverter(
appCurrency = appCurrency,
titleStateProvider = { TokenItemState.TitleState.Content(text = stringReference(value.userWallet.name)) },
subtitleStateProvider = {
TokenItemState.SubtitleState.TextContent(value = stringReference(value.status.currency.name))
},
onItemClick = { _, status -> onTokenItemClick(status) },
)
return PortfolioTokenUM(
tokenItemState = tokenItemStateConverter.convert(value = value.status),
walletId = value.userWallet.walletId,
isBalanceHidden = isBalanceHidden,
isQuickActionsShown = false,
quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler),
)
}
companion object {
fun quickActions(
cryptoData: PortfolioData.CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
): PortfolioTokenUM.QuickActions {
return PortfolioTokenUM.QuickActions(
actions = toQuickActions(cryptoData.actions),
onQuickActionClick = { quickActionUM ->
when (quickActionUM) {
QuickActionUM.Buy -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Buy,
cryptoCurrencyData = cryptoData,
)
is QuickActionUM.Exchange -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Exchange,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Receive -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Receive,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Stake -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Stake,
cryptoCurrencyData = cryptoData,
)
is QuickActionUM.YieldMode -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.YieldMode,
cryptoCurrencyData = cryptoData,
)
}
},
onQuickActionLongClick = { quickAction ->
if (quickAction == QuickActionUM.Receive) {
tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.CopyAddress,
cryptoCurrencyData = cryptoData,
)
}
},
)
}
fun toQuickActions(actions: List<TokenActionsState.ActionState>) = buildList {
actions.forEach { action ->
if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) {
when (action) {
is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy
is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(
shouldShowBadge = action.showBadge,
)
is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive
is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake
is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(apy = action.apy)
else -> null
}?.let(::add)
}
}
}.toImmutableList()
}
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.domain.models.wallet.UserWalletId
/**
* Portfolio UI data. Combined data from all UI flows that required to setup portfolio
*
* @property portfolioBSVisibilityModel portfolio bottom sheet visibility model
* @property selectedWalletId selected wallet id
* @property addToPortfolioData add to portfolio data
* @property shouldRequireColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet
*
[REDACTED_AUTHOR]
*/
internal data class PortfolioUIData(
val portfolioBSVisibilityModel: PortfolioBSVisibilityModel,
val selectedWalletId: UserWalletId?,
val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData,
val shouldRequireColdWalletInteraction: Boolean,
)

View file

@ -1,37 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.markets.portfolio.impl.ui.state.SelectNetworkUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
/**
* Converter from [TokenMarketParams] to [SelectNetworkUM]
*
* @property networksWithToggle map of networks with toggles
* @property alreadyAddedNetworks already added networks
* @property onNetworkSwitchClick callback is called when network switch is clicked
*
[REDACTED_AUTHOR]
*/
internal class SelectNetworkUMConverter(
private val networksWithToggle: Map<TokenMarketInfo.Network, Boolean>,
private val alreadyAddedNetworks: Set<String>,
private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit,
) : Converter<TokenMarketParams, SelectNetworkUM> {
override fun convert(value: TokenMarketParams): SelectNetworkUM {
return SelectNetworkUM(
tokenId = value.id.value,
iconUrl = value.imageUrl,
tokenName = value.name,
tokenCurrencySymbol = value.symbol,
networks = BlockchainRowUMConverter(alreadyAddedNetworks)
.convertList(networksWithToggle.toList())
.toImmutableList(),
onNetworkSwitchClick = { um, isChecked -> onNetworkSwitchClick(um, isChecked) },
)
}
}

View file

@ -1,178 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList")
internal class TokenActionsHandler @AssistedInject constructor(
private val router: Router,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
) {
private val disabledActionsInDemoMode = buildSet {
add(TokenActionsBSContentUM.Action.Sell)
}
fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
onHandleQuickAction(
HandledQuickAction(
action = action,
cryptoCurrencyData = cryptoCurrencyData,
),
)
val userWallet = cryptoCurrencyData.userWallet
if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return
when (action) {
TokenActionsBSContentUM.Action.Buy -> onBuyClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Exchange -> onExchangeClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Receive -> Unit
TokenActionsBSContentUM.Action.CopyAddress -> onCopyAddress(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Sell -> onSellClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Send -> onSendClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Stake -> onStakeClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.YieldMode -> onYieldModeClick(cryptoCurrencyData)
}
}
private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean {
val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId)
val shouldShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action)
if (shouldShowDemoWarning) {
showDemoModeWarning()
}
return shouldShowDemoWarning
}
private fun showDemoModeWarning() {
val message = DialogMessage(
message = resourceReference(R.string.alert_demo_feature_disabled),
)
messageSender.send(message)
}
private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val cryptoCurrencyStatus = cryptoCurrencyData.status
val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return
val addresses = networkAddress.availableAddresses
.mapToAddressModels(cryptoCurrencyStatus.currency)
.toImmutableList()
val defaultAddress = addresses.firstOrNull()?.value ?: return
clipboardManager.setText(text = defaultAddress, isSensitive = true)
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied)))
}
private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
router.push(
AppRoute.Onramp(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
source = OnrampSource.MARKETS,
),
)
}
private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
router.push(
AppRoute.Swap(
currencyFrom = cryptoCurrencyData.status.currency,
userWalletId = cryptoCurrencyData.userWallet.walletId,
isInitialReverseOrder = true,
screenSource = AnalyticsParam.ScreensSources.Markets.value,
),
)
}
private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val route = AppRoute.SendEntryPoint(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
)
router.push(route)
}
private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake }
?.let { it as TokenActionsState.ActionState.Stake }
?.option ?: return
router.push(
AppRoute.Staking(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrency = cryptoCurrencyData.status.currency,
integrationId = option.integrationId,
),
)
}
private fun onYieldModeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance<TokenActionsState.ActionState.YieldMode>()
.firstOrNull()?.apy ?: return
router.push(
AppRoute.YieldSupplyEntry(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrency = cryptoCurrencyData.status.currency,
apy = yieldSupplyApy,
),
)
}
@AssistedFactory
interface Factory {
fun create(
currentAppCurrency: Provider<AppCurrency>,
onHandleQuickAction: (HandledQuickAction) -> Unit,
): TokenActionsHandler
}
data class HandledQuickAction(
val action: TokenActionsBSContentUM.Action,
val cryptoCurrencyData: PortfolioData.CryptoCurrencyData,
)
}

View file

@ -1,113 +0,0 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
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.features.markets.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Converter from [Map] of [UserWallet] and [CryptoCurrencyStatus] to [MyPortfolioUM.Tokens]
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class TokensPortfolioUMConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val addButtonState: AddButtonState,
private val bsConfig: TangemBottomSheetConfig,
private val onAddClick: () -> Unit,
private val quickActionsIntents: TokenActionsHandler,
private val currentState: Provider<MyPortfolioUM>,
private val updateTokens: ((ImmutableList<PortfolioTokenUM>) -> ImmutableList<PortfolioTokenUM>) -> Unit,
) : Converter<Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>, MyPortfolioUM.Tokens> {
override fun convert(value: Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>): MyPortfolioUM.Tokens {
val currentTokensState = currentState() as? MyPortfolioUM.Tokens
return MyPortfolioUM.Tokens(
tokens = value
.flatMap { entry -> entry.value }
.map { cryptoData ->
PortfolioTokenUMConverter(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
onTokenItemClick = { toggleQuickActions(cryptoData) },
tokenActionsHandler = quickActionsIntents,
).convert(value = cryptoData) to cryptoData
}
.setQuickActionsVisibility(currentState = currentTokensState)
.toImmutableList(),
buttonState = addButtonState,
addToPortfolioBSConfig = bsConfig,
onAddClick = onAddClick,
)
}
private fun List<Pair<PortfolioTokenUM, PortfolioData.CryptoCurrencyData>>.setQuickActionsVisibility(
currentState: MyPortfolioUM.Tokens?,
): List<PortfolioTokenUM> {
return when {
// if there is only one token and it has empty balance, show quick actions for it
currentState == null && this.size == 1 && isEmptyBalance(this.first().second) -> {
this.map { (token, _) ->
token.copy(isQuickActionsShown = true)
}
}
// if there is no previous state, hide quick actions for all tokens
currentState == null -> {
this.map { (token, _) ->
token.copy(isQuickActionsShown = false)
}
}
else -> {
val previousList = currentState.tokens
// otherwise, keep previous state
this.map { (token, _) ->
token.copy(
isQuickActionsShown = previousList
.firstOrNull { it.matchWith(token) }
?.isQuickActionsShown == true,
)
}
}
}
}
private fun isEmptyBalance(cryptoData: PortfolioData.CryptoCurrencyData): Boolean {
return cryptoData.status.value.amount?.isZero() == true
}
private fun toggleQuickActions(cryptoData: PortfolioData.CryptoCurrencyData) {
updateTokens { tokenList ->
tokenList.map { token ->
token.copy(
isQuickActionsShown = if (token.matchWith(cryptoData)) {
!token.isQuickActionsShown
} else {
false
},
)
}.toImmutableList()
}
}
private fun PortfolioTokenUM.matchWith(token: PortfolioTokenUM): Boolean {
return this.walletId == token.walletId && this.tokenItemState.id == token.tokenItemState.id
}
private fun PortfolioTokenUM.matchWith(cryptoData: PortfolioData.CryptoCurrencyData): Boolean {
return this.walletId == cryptoData.userWallet.walletId &&
this.tokenItemState.id == cryptoData.status.currency.id.value
}
}

View file

@ -1,383 +0,0 @@
package com.tangem.features.markets.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.rows.ArrowRow
import com.tangem.core.ui.components.rows.BlockchainRow
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider
import com.tangem.features.markets.portfolio.impl.ui.state.AddToPortfolioBSContentUM
import com.tangem.features.markets.portfolio.impl.ui.state.SelectNetworkUM
import kotlinx.coroutines.delay
@Composable
internal fun AddToPortfolioBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<AddToPortfolioBSContentUM>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
addBottomInsets = false,
titleText = resourceReference(R.string.common_add_to_portfolio),
) { contentState ->
Content(
modifier = Modifier.fillMaxWidth(),
state = contentState,
)
WalletSelectorBottomSheet(contentState.walletSelectorConfig)
}
}
@Composable
private fun Content(state: AddToPortfolioBSContentUM, modifier: Modifier = Modifier) {
var continueButtonAreaHeight by remember { mutableIntStateOf(0) }
val density = LocalDensity.current
val scrollState = rememberScrollState()
Box(modifier = modifier) {
Column(
modifier = Modifier
.verticalScroll(state = scrollState)
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
if (state.isWalletBlockVisible) {
UserWalletItem(
state = state.selectedWallet,
blockColors = TangemBlockCardColors.copy(
containerColor = TangemTheme.colors.background.action,
disabledContainerColor = TangemTheme.colors.background.action,
),
)
SpacerH12()
}
NetworkSelection(
modifier = Modifier.fillMaxWidth(),
state = state.selectNetworkUM,
)
SpacerH12()
AnimatedVisibility(
visible = state.isScanCardNotificationVisible,
modifier = Modifier.fillMaxWidth(),
) {
Column {
ScanWalletWarning(modifier = Modifier.fillMaxWidth())
SpacerH12()
}
// Scroll to the bottom when the notification appears and the scroll is at the bottom
LaunchedEffect(Unit) {
if (scrollState.canScrollForward.not()) {
delay(timeMillis = 500)
scrollState.animateScrollTo(scrollState.maxValue)
}
}
}
SpacerH(with(density) { continueButtonAreaHeight.toDp() })
}
AnimatedVisibility(
visible = scrollState.canScrollForward,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter),
) {
BottomFade(Modifier.align(Alignment.BottomCenter))
}
ContinueButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.onGloballyPositioned {
continueButtonAreaHeight = it.size.height
},
enabled = state.isContinueButtonEnabled,
isTangemIconVisible = state.isScanCardNotificationVisible,
onClick = state.onContinueButtonClick,
)
}
}
@Composable
private fun ContinueButton(
enabled: Boolean,
isTangemIconVisible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TangemButton(
enabled = enabled,
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
)
.navigationBarsPadding()
.fillMaxWidth(),
text = stringResourceSafe(R.string.common_continue),
icon = if (enabled && isTangemIconVisible) {
TangemButtonIconPosition.End(R.drawable.ic_tangem_24)
} else {
TangemButtonIconPosition.None
},
showProgress = false,
size = TangemButtonSize.Default,
colors = TangemButtonsDefaults.primaryButtonColors,
textStyle = TangemTheme.typography.subtitle1,
onClick = onClick,
animateContentChange = true,
)
}
@Suppress("LongMethod")
@Composable
private fun NetworkSelection(state: SelectNetworkUM, modifier: Modifier = Modifier) {
val hapticManager = LocalHapticManager.current
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResourceSafe(R.string.markets_select_network),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing14),
verticalAlignment = Alignment.CenterVertically,
) {
CoinIcon(
modifier = Modifier.size(TangemTheme.dimens.size36),
url = state.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
SpacerW12()
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.weight(1f, fill = false)
.alignByBaseline(),
text = state.tokenName,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW6()
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.alignByBaseline(),
text = state.tokenCurrencySymbol,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Visible,
maxLines = 1,
)
}
state.networks.fastForEachIndexed { index, network ->
ArrowRow(
isLastItem = index == state.networks.lastIndex,
content = {
BlockchainRow(
modifier = Modifier.padding(end = TangemTheme.dimens.spacing4),
model = network,
action = {
TangemSwitch(
checked = network.isSelected,
checkedColor = if (network.isEnabled) {
TangemTheme.colors.control.checked
} else {
TangemTheme.colors.icon.inactive
},
onCheckedChange = { checked ->
if (checked) {
hapticManager.perform(TangemHapticEffect.View.ToggleOn)
} else {
hapticManager.perform(TangemHapticEffect.View.ToggleOff)
}
state.onNetworkSwitchClick(network, checked)
},
enabled = network.isEnabled,
)
},
)
},
)
}
}
}
}
@Composable
private fun ScanWalletWarning(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(
color = TangemTheme.colors.button.disabled,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10),
) {
Icon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size20),
imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.markets_generate_addresses_notification),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview {
AddToPortfolioBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
content = content,
onDismissRequest = {},
),
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun PreviewContent(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview {
Content(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.fillMaxWidth(),
state = content,
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun PreviewContentRtl(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview(rtl = true) {
Content(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.fillMaxWidth(),
state = content,
)
}
}
// For on device testing
@Composable
@Preview
private fun PreviewContentTestOnDevice(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview(
alwaysShowBottomSheets = false,
) {
var isShow by remember { mutableStateOf(false) }
var contentState by remember {
mutableStateOf(content)
}
LaunchedEffect(Unit) {
contentState = content.copy(
onContinueButtonClick = {
contentState = contentState.copy(
isScanCardNotificationVisible = !contentState.isScanCardNotificationVisible,
)
},
isContinueButtonEnabled = true,
selectedWallet = content.selectedWallet.copy(
onClick = {
contentState = contentState.copy(
isContinueButtonEnabled = !contentState.isContinueButtonEnabled,
)
},
),
)
}
AddToPortfolioBottomSheet(
config = TangemBottomSheetConfig(
isShown = isShow,
content = contentState,
onDismissRequest = { isShow = false },
),
)
Button(
onClick = { isShow = !isShow },
) {
Text(text = "Toggle")
}
}
}

View file

@ -1,339 +0,0 @@
package com.tangem.features.markets.portfolio.impl.ui
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.common.ui.account.AccountTitle
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SmallButtonShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
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.*
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState
@Composable
internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) {
if (state is MyPortfolioUM.Content) {
val contentModifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing20,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing32,
)
PortfolioList(state, contentModifier)
return
}
InformationBlock(
modifier = modifier,
contentHorizontalPadding = TangemTheme.dimens.spacing0,
title = { Title() },
action = {
if (state !is MyPortfolioUM.Tokens) return@InformationBlock
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, modifier = contentModifier)
MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier)
MyPortfolioUM.Unavailable -> UnavailableAsset(modifier = contentModifier)
MyPortfolioUM.UnavailableForWallet -> UnavailableAssetForWallet(modifier = contentModifier)
is MyPortfolioUM.Content -> PortfolioList(state = state)
}
}
val bsConfig = state.addToPortfolioBSConfig
if (bsConfig != null) {
AddToPortfolioBottomSheet(config = bsConfig)
}
}
@Composable
private fun Title() {
Text(
text = stringResourceSafe(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 -> {
Box {
SmallButtonShimmer(
modifier = Modifier.width(width = TangemTheme.dimens.size63),
shape = RoundedCornerShape(TangemTheme.dimens.radius3),
withIcon = true,
)
Box(
Modifier
.matchParentSize()
.background(TangemTheme.colors.background.action),
)
RectangleShimmer(
modifier = Modifier
.align(Alignment.Center)
.size(width = TangemTheme.dimens.size63, height = TangemTheme.dimens.size18),
radius = 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,
isEnabled = state == AddButtonState.Available,
),
)
}
}
}
@Composable
private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier) {
Column(modifier) {
state.tokens.fastForEachIndexed { index, token ->
key(token.tokenItemState.id) {
PortfolioItem(
modifier = Modifier.background(color = TangemTheme.colors.background.action),
state = token,
lastInList = index == state.tokens.size - 1,
)
}
}
}
}
@Composable
private fun PortfolioList(state: MyPortfolioUM.Content, modifier: Modifier = Modifier) {
Column(modifier) {
key("PortfolioListHeader") {
Row(
modifier = Modifier.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = stringResourceSafe(R.string.markets_common_my_portfolio),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
AddButton(state = state.buttonState, onClick = state.onAddClick)
}
}
state.items.fastForEachIndexed { index, item ->
val previousItem = state.items.getOrNull(index.dec())
val nextItem = state.items.getOrNull(index.inc())
val itemModifier = Modifier
.fillMaxWidth()
.getOffsetModifier(item, previousItem)
.getBackgroundModifier(item, previousItem, nextItem)
key(item.id) {
PortfolioItem(
item = item,
modifier = itemModifier,
lastInList = index == state.items.size - 1,
)
}
}
}
}
@Composable
private fun Modifier.getBackgroundModifier(
item: PortfolioListItem,
previousItem: PortfolioListItem?,
nextItem: PortfolioListItem?,
): Modifier {
val color = TangemTheme.colors.background.action
val radius = 14.dp
val topRound = RoundedCornerShape(topStart = radius, topEnd = radius)
val bottomRound = RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
val allRound = RoundedCornerShape(size = radius)
val backgroundModifier = when (item) {
is WalletHeader -> this
is PortfolioHeader -> this
.clip(topRound)
.background(color = color)
is PortfolioTokenUM -> when {
previousItem is PortfolioHeader && nextItem !is PortfolioTokenUM -> this
.clip(bottomRound)
.background(color = color)
previousItem is WalletHeader && nextItem !is PortfolioTokenUM -> this
.clip(allRound)
.background(color = color)
previousItem is PortfolioTokenUM && nextItem !is PortfolioTokenUM -> this
.clip(bottomRound)
.background(color = color)
else -> this.background(color = color)
}
}
return backgroundModifier
}
private fun Modifier.getOffsetModifier(item: PortfolioListItem, previousItem: PortfolioListItem?): Modifier = when {
item is WalletHeader -> this.padding(top = 20.dp, start = 4.dp, end = 4.dp)
item is PortfolioHeader && previousItem is PortfolioTokenUM -> this.padding(top = 12.dp)
item is PortfolioHeader && previousItem == null -> this.padding(top = 20.dp)
previousItem is WalletHeader -> this.padding(top = 12.dp)
previousItem is PortfolioTokenUM -> this
else -> this
}
@Composable
private fun PortfolioItem(item: PortfolioListItem, lastInList: Boolean, modifier: Modifier = Modifier) {
when (item) {
is PortfolioHeader -> AccountTitle(
modifier = modifier.padding(
start = 12.dp,
top = 12.dp,
bottom = 8.dp,
),
accountTitleUM = item.state,
textStyle = TangemTheme.typography.caption1,
textColor = TangemTheme.colors.text.primary1,
)
is PortfolioTokenUM -> PortfolioItem(
state = item,
modifier = modifier,
lastInList = lastInList,
)
is WalletHeader -> Text(
modifier = modifier,
text = item.name.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
}
}
@Composable
fun UnavailableAsset(modifier: Modifier = Modifier) {
UnavailableContent(
textId = R.string.markets_add_to_my_portfolio_unavailable_description,
modifier = modifier,
)
}
@Composable
fun UnavailableAssetForWallet(modifier: Modifier = Modifier) {
UnavailableContent(
textId = R.string.markets_add_to_my_portfolio_unavailable_for_wallet_description,
modifier = modifier,
)
}
@Composable
private fun UnavailableContent(@StringRes textId: Int, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = stringResourceSafe(textId),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun AddFirstTokenContent(state: MyPortfolioUM.AddFirstToken, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Text(
text = stringResourceSafe(R.string.markets_add_to_my_portfolio_description),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_add_to_portfolio),
onClick = state.onAddClick,
)
}
}
@Composable
private fun LoadingPlaceholder(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.7f),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) {
TangemThemePreview {
Box(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary),
) {
MyPortfolio(state)
}
}
}
@Preview
@Composable
private fun PreviewRtl(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) {
TangemThemePreview(rtl = true) {
Box(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing8),
) {
MyPortfolio(state)
}
}
}

View file

@ -1,155 +0,0 @@
package com.tangem.features.markets.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
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.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
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.PortfolioTokenUM
import com.tangem.utils.StringsSigns.DASH_SIGN
import kotlinx.collections.immutable.persistentListOf
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState as TokenFiatAmountState
@Composable
internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifier: Modifier = Modifier) {
Column(modifier) {
val hapticManager = LocalHapticManager.current
val tokenItemState = remember(state.tokenItemState) {
when (state.tokenItemState) {
is TokenItemState.Content -> state.tokenItemState.copy(
onItemClick = { cryptoCurrency ->
val onClick = state.tokenItemState.onItemClick
if (onClick != null) {
hapticManager.perform(TangemHapticEffect.View.ContextClick)
onClick.invoke(cryptoCurrency)
}
},
)
else -> state.tokenItemState
}
}
TokenItem(
state = tokenItemState,
isBalanceHidden = state.isBalanceHidden,
itemPaddingValues = PaddingValues(
start = TangemTheme.dimens.spacing10,
end = TangemTheme.dimens.spacing12,
),
)
PortfolioQuickActions(
modifier = Modifier
.padding(
bottom = if (lastInList) {
TangemTheme.dimens.spacing12
} else {
TangemTheme.dimens.spacing24
},
),
actions = state.quickActions.actions,
isVisible = state.isQuickActionsShown,
onActionClick = state.quickActions.onQuickActionClick,
onActionLongClick = state.quickActions.onQuickActionLongClick,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: PortfolioTokenUM) {
TangemThemePreview {
var areQuickActionsShown by remember { mutableStateOf(value = false) }
val onItemClick = {
areQuickActionsShown = areQuickActionsShown.not()
}
PortfolioItem(
modifier = Modifier.background(color = TangemTheme.colors.background.action),
state = tokenUM.copy(
tokenItemState = when (tokenUM.tokenItemState) {
is TokenItemState.Content -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() })
is TokenItemState.Unreachable -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() })
else -> tokenUM.tokenItemState
},
isQuickActionsShown = areQuickActionsShown,
),
lastInList = true,
)
}
}
private class PortfolioTokenUMProvider : CollectionPreviewParameterProvider<PortfolioTokenUM>(
collection = listOf(
tokenUM.copy(
tokenItemState = (tokenUM.tokenItemState as TokenItemState.Content).copy(
fiatAmountState = contentFiatAmount.copy(
icons = persistentListOf(
TokenFiatAmountState.Content.IconUM(
iconRes = R.drawable.ic_staking_24,
tint = IconTint.Accent,
),
),
),
),
),
tokenUM.copy(
tokenItemState = tokenUM.tokenItemState.copy(
fiatAmountState = contentFiatAmount.copy(text = DASH_SIGN),
subtitle2State = (tokenUM.tokenItemState.subtitle2State as? TokenItemState.Subtitle2State.TextContent
?: error("subtitle2State must be TextContent for preview"))
.copy(text = DASH_SIGN),
),
),
tokenUM.copy(isBalanceHidden = true),
tokenUM.copy(
tokenItemState = TokenItemState.Unreachable(
id = tokenUM.tokenItemState.id,
iconState = tokenUM.tokenItemState.iconState,
titleState = tokenUM.tokenItemState.titleState,
subtitleState = tokenUM.tokenItemState.subtitleState,
onItemClick = {},
onItemLongClick = {},
),
),
tokenUM.copy(
tokenItemState = TokenItemState.NoAddress(
id = tokenUM.tokenItemState.id,
iconState = tokenUM.tokenItemState.iconState,
titleState = tokenUM.tokenItemState.titleState,
subtitleState = tokenUM.tokenItemState.subtitleState,
onItemLongClick = {},
),
),
tokenUM.copy(
tokenItemState = TokenItemState.Loading(
id = tokenUM.tokenItemState.id,
iconState = tokenUM.tokenItemState.iconState,
titleState = tokenUM.tokenItemState.titleState as TokenItemState.TitleState.Content,
subtitleState = tokenUM.tokenItemState.subtitleState,
),
),
),
) {
companion object {
val tokenUM = PreviewMyPortfolioUMProvider().sampleToken
val contentFiatAmount = tokenUM.tokenItemState.fiatAmountState as? TokenFiatAmountState.Content
?: error("fiatAmountState must be Content for preview")
}
}

View file

@ -1,249 +0,0 @@
package com.tangem.features.markets.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.icons.badge.drawBadge
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun PortfolioQuickActions(
actions: ImmutableList<QuickActionUM>,
isVisible: Boolean,
onActionClick: (QuickActionUM) -> Unit,
onActionLongClick: (QuickActionUM) -> Unit,
modifier: Modifier = Modifier,
) {
if (actions.isEmpty()) return
AnimatedVisibility(
visible = isVisible,
enter = expandVertically(expandFrom = Alignment.Top),
exit = shrinkVertically(shrinkTowards = Alignment.Top),
modifier = modifier,
) {
Column(modifier = Modifier) {
actions.fastForEach { action ->
LineSeparator()
QuickActionItem(
state = action,
onClick = { onActionClick(action) },
onLongClick = { onActionLongClick(action) }.takeIf { action.isLongClickAvailable },
)
}
}
}
}
@Composable
private fun AnimatedVisibilityScope.LineSeparator(modifier: Modifier = Modifier) {
val lineColor = TangemTheme.colors.stroke.primary
val strokeWidth = TangemTheme.dimens.size1
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val startPadding = TangemTheme.dimens.spacing30
val height = TangemTheme.dimens.size16
Canvas(
modifier = modifier
.animateEnterExit(
enter = expandVertically(
animationSpec = spring(
stiffness = Spring.StiffnessLow,
),
expandFrom = Alignment.Top,
) + fadeIn(),
exit = shrinkVertically(
spring(
stiffness = Spring.StiffnessLow,
),
shrinkTowards = Alignment.Top,
) + fadeOut(),
)
.fillMaxWidth()
.height(height),
) {
val x = if (isLtr) startPadding.toPx() else size.width - startPadding.toPx()
drawLine(
color = lineColor,
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = strokeWidth.toPx(),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AnimatedVisibilityScope.QuickActionItem(
state: QuickActionUM,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
val onLongClickInternal: (() -> Unit)? = if (onLongClick != null) {
{
hapticManager.perform(TangemHapticEffect.View.LongPress)
onLongClick()
}
} else {
null
}
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onLongClick = onLongClickInternal,
onClick = {
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
onClick()
},
)
.padding(horizontal = TangemTheme.dimens.spacing14, vertical = TangemTheme.dimens.spacing4),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing18),
) {
QuickActionIcon(state)
Column(
modifier = Modifier
.animateEnterExit(
enter = fadeIn(),
exit = fadeOut(),
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = state.description.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable
private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) {
val containerColor = TangemTheme.colors.background.action
Box(
Modifier
.animateEnterExit(
enter = scaleIn(),
exit = scaleOut(),
)
.background(
color = TangemTheme.colors.button.secondary,
shape = CircleShape,
)
.size(TangemTheme.dimens.size32)
.drawWithContent {
drawContent()
if (state is QuickActionUM.Exchange && state.shouldShowBadge) {
drawBadge(containerColor = containerColor, offset = 4.dp)
}
},
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier
.requiredSize(TangemTheme.dimens.size16),
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
tint = TangemTheme.colors.button.primary,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
var isVisible by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxWidth()
.height(680.dp),
) {
Button(
onClick = { isVisible = !isVisible },
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
) {
Text(text = "Toggle")
}
SpacerH4()
Box(
modifier = Modifier.background(color = TangemTheme.colors.background.action),
) {
PortfolioQuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),
QuickActionUM.Receive,
),
isVisible = isVisible,
onActionClick = {},
onActionLongClick = {},
)
}
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewRtl() {
TangemThemePreview(rtl = true) {
Box(modifier = Modifier.background(color = TangemTheme.colors.background.action)) {
PortfolioQuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),
QuickActionUM.Receive,
),
isVisible = true,
onActionClick = {},
onActionLongClick = {},
)
}
}
}

View file

@ -1,86 +0,0 @@
package com.tangem.features.markets.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SimpleSettingsRow
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
import kotlinx.collections.immutable.toImmutableList
@Composable
fun TokenActionsBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<TokenActionsBSContentUM>(
config = config,
title = { content ->
TangemBottomSheetTitle(content.title)
},
containerColor = TangemTheme.colors.background.tertiary,
content = { Content(it) },
)
}
@Composable
private fun Content(content: TokenActionsBSContentUM) {
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
content.actions.forEachIndexed { index, action ->
Box(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = content.actions.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action),
) {
SimpleSettingsRow(
title = action.text.resolveReference(),
icon = action.iconRes,
redesign = true,
onItemsClick = { content.onActionClick(action) },
)
}
}
}
}
@Preview(widthDp = 360, heightDp = 640)
@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview(
alwaysShowBottomSheets = true,
) {
Box(Modifier.background(TangemTheme.colors.background.secondary)) {
TokenActionsBottomSheet(
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = TokenActionsBSContentUM(
title = "Wallet 1",
actions = TokenActionsBSContentUM.Action.entries.toImmutableList(),
onActionClick = {},
),
),
)
}
}
}

Some files were not shown because too many files have changed in this diff Show more