Updated on 2026-08-14
This commit is contained in:
parent
297a006e3d
commit
7b7cece46f
35 changed files with 436 additions and 422 deletions
|
|
@ -41,7 +41,7 @@ object MarketsDomainModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetTokenQuotesUseCase(quotesRepository: QuotesRepository): GetTokenQuotesUseCase {
|
||||
return GetTokenQuotesUseCase(quotesRepository = quotesRepository)
|
||||
fun provideGetTokenQuotesUseCase(quotesRepository: QuotesRepository): GetCurrencyQuotesUseCase {
|
||||
return GetCurrencyQuotesUseCase(quotesRepository = quotesRepository)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.tangem.common.ui.tokens
|
||||
|
||||
import androidx.compose.animation.Animatable
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
/**
|
||||
* Text view for token price.
|
||||
*
|
||||
* @param price Price of the token.
|
||||
* @param priceChangeType Type of the price change.
|
||||
*/
|
||||
@Composable
|
||||
fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
|
||||
val growColor = TangemTheme.colors.text.accent
|
||||
val fallColor = TangemTheme.colors.text.warning
|
||||
val generalColor = TangemTheme.colors.text.primary1
|
||||
|
||||
val color = remember(generalColor) { Animatable(generalColor) }
|
||||
var animationSkipped by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(price) {
|
||||
if (animationSkipped.not()) {
|
||||
animationSkipped = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (priceChangeType != null) {
|
||||
val nextColor = when (priceChangeType) {
|
||||
PriceChangeType.UP,
|
||||
-> growColor
|
||||
PriceChangeType.DOWN -> fallColor
|
||||
PriceChangeType.NEUTRAL -> return@LaunchedEffect
|
||||
}
|
||||
|
||||
color.animateTo(nextColor, snap())
|
||||
color.animateTo(generalColor, tween(durationMillis = 500))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = price,
|
||||
color = color.value,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.body2,
|
||||
overflow = TextOverflow.Visible,
|
||||
)
|
||||
}
|
||||
|
|
@ -67,7 +67,16 @@ data class TokenMarketInfoResponse(
|
|||
val buyPressureChange: Change?,
|
||||
@Json(name = "experienced_buyer_change")
|
||||
val experiencedBuyerChange: Change?,
|
||||
)
|
||||
@Json(name = "networks")
|
||||
val sourceNetworks: List<SourceNetwork>?,
|
||||
) {
|
||||
data class SourceNetwork(
|
||||
@Json(name = "network_id")
|
||||
val id: String,
|
||||
@Json(name = "network_name")
|
||||
val name: String,
|
||||
)
|
||||
}
|
||||
|
||||
data class Change(
|
||||
@Json(name = "24h")
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.focus.FocusManager
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.SoftwareKeyboardController
|
||||
|
|
@ -69,6 +70,7 @@ fun SearchBar(state: SearchBarUM, modifier: Modifier = Modifier, colors: TextFie
|
|||
textStyle = TangemTheme.typography.body2.copy(
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
),
|
||||
cursorBrush = SolidColor(TangemTheme.colors.icon.primary1),
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
DecorationBox(
|
||||
state = state,
|
||||
|
|
|
|||
|
|
@ -336,6 +336,15 @@ object BigDecimalFormatter {
|
|||
): String {
|
||||
if (amount == null) return EMPTY_BALANCE_SIGN
|
||||
|
||||
if (amount < BigDecimal.ONE) {
|
||||
return formatFiatPriceUncapped(
|
||||
fiatAmount = amount,
|
||||
fiatCurrencyCode = fiatCurrencyCode,
|
||||
fiatCurrencySymbol = fiatCurrencySymbol,
|
||||
locale = locale,
|
||||
)
|
||||
}
|
||||
|
||||
val rawAmount = formatCompactAmount(
|
||||
amount = amount,
|
||||
locale = locale,
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ object StringsSigns {
|
|||
const val DASH_SIGN = "—"
|
||||
const val LOWER_SIGN = "<"
|
||||
const val TILDE_SIGN = "~"
|
||||
const val NON_BREAKING_SPACE = '\u00A0'
|
||||
}
|
||||
|
|
@ -7,8 +7,10 @@ import kotlinx.coroutines.yield
|
|||
import timber.log.Timber
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Suppress("UnconditionalJumpStatementInLoop")
|
||||
suspend fun <T> retryOnError(priority: Boolean = false, call: suspend () -> T): T {
|
||||
@Suppress("UnconditionalJumpStatementInLoop", "MagicNumber")
|
||||
suspend fun <T> retryOnError(priority: Boolean = false, startRetryDelay: Int = 500, call: suspend () -> T): T {
|
||||
var currentDelay = startRetryDelay
|
||||
var priorityCounter = 5
|
||||
while (true) {
|
||||
return try {
|
||||
call()
|
||||
|
|
@ -19,9 +21,12 @@ suspend fun <T> retryOnError(priority: Boolean = false, call: suspend () -> T):
|
|||
|
||||
Timber.e(e, "Error occurred during retryOnError block")
|
||||
|
||||
if (priority.not()) {
|
||||
if (priority && priorityCounter > 0) {
|
||||
--priorityCounter
|
||||
} else {
|
||||
yield()
|
||||
delay(timeMillis = 500)
|
||||
delay(timeMillis = currentDelay.toLong())
|
||||
currentDelay *= 2
|
||||
}
|
||||
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ internal object TokenMarketInfoConverter : Converter<TokenMarketInfoResponse, To
|
|||
liquidityChange = liquidityChange?.convert(),
|
||||
buyPressureChange = buyPressureChange?.convert(),
|
||||
experiencedBuyerChange = experiencedBuyerChange?.convert(),
|
||||
sourceNetworks = sourceNetworks.orEmpty().map { TokenMarketInfo.Insights.SourceNetwork(it.id, it.name) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,14 +37,14 @@ internal class DefaultQuotesRepository(
|
|||
private val mutex = Mutex()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getQuotesUpdates(currenciesIds: Set<CryptoCurrency.ID>): Flow<Set<Quote>> {
|
||||
override fun getQuotesUpdates(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Flow<Set<Quote>> {
|
||||
return appPreferencesStore.getObject<CurrenciesResponse.Currency>(
|
||||
key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
|
||||
)
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.flatMapLatest { appCurrency ->
|
||||
fetchExpiredQuotes(currenciesIds, appCurrency.id, refresh = false)
|
||||
fetchExpiredQuotes(currenciesIds, appCurrency.id, refresh = refresh)
|
||||
quotesStore.get(currenciesIds).map(quotesConverter::convertSet)
|
||||
}
|
||||
.cancellable()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ data class TokenMarketInfo(
|
|||
val liquidityChange: Change?,
|
||||
val buyPressureChange: Change?,
|
||||
val experiencedBuyerChange: Change?,
|
||||
)
|
||||
val sourceNetworks: List<SourceNetwork>,
|
||||
) {
|
||||
data class SourceNetwork(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
}
|
||||
|
||||
data class Change(
|
||||
val day: BigDecimal?,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.domain.markets
|
||||
|
||||
import arrow.core.*
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.domain.tokens.repository.QuotesRepository
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
class GetCurrencyQuotesUseCase(
|
||||
private val quotesRepository: QuotesRepository,
|
||||
) {
|
||||
// TODO apply interval parameter [REDACTED_TASK_KEY]
|
||||
operator fun invoke(
|
||||
currencyID: CryptoCurrency.ID,
|
||||
interval: PriceChangeInterval,
|
||||
refresh: Boolean,
|
||||
): Flow<Option<Quote>> {
|
||||
return quotesRepository.getQuotesUpdates(
|
||||
currenciesIds = setOf(currencyID),
|
||||
refresh = refresh,
|
||||
).map { it.firstOrNull().toOption() }.catch { emit(None) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package com.tangem.domain.markets
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.domain.tokens.repository.QuotesRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
class GetTokenQuotesUseCase(
|
||||
private val quotesRepository: QuotesRepository,
|
||||
) {
|
||||
operator fun invoke(tokenId: String, interval: PriceChangeInterval): Flow<Either<Unit, Quote>> {
|
||||
return flowOf(
|
||||
Either.catch {
|
||||
Quote(
|
||||
rawCurrencyId = "USD",
|
||||
fiatRate = 100.toBigDecimal(), // mock
|
||||
priceChange = 10.toBigDecimal(), // mock
|
||||
)
|
||||
}.mapLeft {},
|
||||
)
|
||||
// TODO implement quotes fetching from repository [REDACTED_TASK_KEY]
|
||||
// quotesRepository.getQuotesUpdates()
|
||||
}
|
||||
}
|
||||
|
|
@ -12,12 +12,12 @@ interface QuotesRepository {
|
|||
/**
|
||||
* Retrieves updates of quotes for a set of specified cryptocurrencies, identified by their unique IDs.
|
||||
*
|
||||
* Loads remote quotes if they have expired.
|
||||
* Loads remote quotes if they have expired or if [refresh] is `true`.
|
||||
*
|
||||
* @param currenciesIds The unique identifiers of the cryptocurrencies for which quotes are to be retrieved.
|
||||
* @return A [Flow] emitting a set of quotes corresponding to the specified cryptocurrencies.
|
||||
*/
|
||||
fun getQuotesUpdates(currenciesIds: Set<CryptoCurrency.ID>): Flow<Set<Quote>>
|
||||
fun getQuotesUpdates(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean = false): Flow<Set<Quote>>
|
||||
|
||||
/**
|
||||
* Retrieves quotes for a set of specified cryptocurrencies, identified by their unique IDs.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ internal class MockQuotesRepository(
|
|||
private val quotes: Flow<Either<DataError, Set<Quote>>>,
|
||||
) : QuotesRepository {
|
||||
|
||||
override fun getQuotesUpdates(currenciesIds: Set<CryptoCurrency.ID>): Flow<Set<Quote>> {
|
||||
override fun getQuotesUpdates(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Flow<Set<Quote>> {
|
||||
return quotes.map { it.getOrElse { e -> throw e } }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.tangem.features.markets.token.block
|
|||
import androidx.compose.runtime.Stable
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Stable
|
||||
|
|
@ -10,6 +11,7 @@ interface TokenMarketBlockComponent : ComposableContentComponent {
|
|||
|
||||
@Serializable
|
||||
data class Params(
|
||||
val cryptoCurrencyID: CryptoCurrency.ID,
|
||||
val tokenId: String,
|
||||
val tokenName: String,
|
||||
val tokenSymbol: String,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ 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.event.triggeredEvent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.utils.BigDecimalFormatter
|
||||
|
|
@ -24,6 +23,10 @@ import com.tangem.features.markets.details.MarketsTokenDetailsComponent
|
|||
import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter
|
||||
import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter
|
||||
import com.tangem.features.markets.details.impl.model.formatter.*
|
||||
import com.tangem.features.markets.details.impl.model.formatter.formatAsPrice
|
||||
import com.tangem.features.markets.details.impl.model.formatter.getChangePercentBetween
|
||||
import com.tangem.features.markets.details.impl.model.formatter.getPercentByInterval
|
||||
import com.tangem.features.markets.details.impl.model.state.QuotesStateUpdater
|
||||
import com.tangem.features.markets.details.impl.model.state.TokenNetworksState
|
||||
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
|
||||
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
|
||||
|
|
@ -76,6 +79,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
urlOpener.openUrl(it.url)
|
||||
},
|
||||
)
|
||||
|
||||
private val descriptionConverter = DescriptionConverter(
|
||||
onReadModeClicked = {
|
||||
showInfoBottomSheet(it)
|
||||
|
|
@ -95,7 +99,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
BigDecimalFormatter.formatFiatPriceUncapped(
|
||||
fiatAmount = value,
|
||||
fiatCurrencyCode = currentAppCurrency.value.code,
|
||||
fiatCurrencySymbol = "",
|
||||
fiatCurrencySymbol = currentAppCurrency.value.symbol,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -115,7 +119,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
),
|
||||
)
|
||||
|
||||
private var lastUpdatedTimestamp: Long = DateTime.now().millis
|
||||
private val currentTokenInfo = MutableStateFlow<TokenMarketInfo?>(null)
|
||||
private val lastUpdatedTimestamp = MutableStateFlow(DateTime.now().millis)
|
||||
|
||||
val isVisibleOnScreen = MutableStateFlow(false)
|
||||
val networksState = MutableStateFlow<TokenNetworksState>(TokenNetworksState.Loading)
|
||||
|
|
@ -155,6 +160,14 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
),
|
||||
)
|
||||
|
||||
private val quotesStateUpdater = QuotesStateUpdater(
|
||||
currentAppCurrency = Provider { currentAppCurrency.value },
|
||||
state = state,
|
||||
currentQuotes = currentQuotes,
|
||||
lastUpdatedTimestamp = lastUpdatedTimestamp,
|
||||
currentTokenInfo = currentTokenInfo,
|
||||
)
|
||||
|
||||
private val loadChartJobHolder = JobHolder()
|
||||
|
||||
init {
|
||||
|
|
@ -279,41 +292,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
)
|
||||
|
||||
tokenMarketInfo.fold(
|
||||
ifRight = { result ->
|
||||
currentQuotes.value = result.quotes
|
||||
val percent = result.quotes.getPercentByInterval(interval = state.value.selectedInterval)
|
||||
state.update {
|
||||
it.copy(
|
||||
priceText = result.quotes.currentPrice.formatAsPrice(currentAppCurrency.value),
|
||||
priceChangePercentText = result.quotes.getFormattedPercentByInterval(
|
||||
interval = it.selectedInterval,
|
||||
),
|
||||
priceChangeType = percent.percentChangeType(),
|
||||
body = MarketsTokenDetailsUM.Body.Content(
|
||||
description = descriptionConverter.convert(result),
|
||||
infoBlocks = infoConverter.convert(result),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val networks = result.networks?.filter {
|
||||
BlockchainUtils.isSupportedNetworkId(it.networkId)
|
||||
}
|
||||
|
||||
networksState.value = if (networks.isNullOrEmpty()) {
|
||||
TokenNetworksState.NoNetworksAvailable
|
||||
} else {
|
||||
TokenNetworksState.NetworksAvailable(networks)
|
||||
}
|
||||
|
||||
chartDataProducer.runTransaction {
|
||||
updateLook {
|
||||
it.copy(
|
||||
type = getChartTypeByPercent(percent),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
ifRight = { result -> updateInfo(result) },
|
||||
ifLeft = {
|
||||
state.update {
|
||||
if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) {
|
||||
|
|
@ -333,42 +312,50 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private 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()) }
|
||||
},
|
||||
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 {
|
||||
it.copy(
|
||||
priceText = newInfo.quotes.currentPrice.formatAsPrice(currentAppCurrency.value),
|
||||
priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval(
|
||||
interval = it.selectedInterval,
|
||||
),
|
||||
priceChangeType = percent.percentChangeType(),
|
||||
body = MarketsTokenDetailsUM.Body.Content(
|
||||
description = descriptionConverter.convert(newInfo),
|
||||
infoBlocks = infoConverter.convert(newInfo),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
consumedEvent()
|
||||
}
|
||||
|
||||
val networks = newInfo.networks?.filter {
|
||||
BlockchainUtils.isSupportedNetworkId(it.networkId)
|
||||
}
|
||||
|
||||
networksState.value = if (networks.isNullOrEmpty()) {
|
||||
TokenNetworksState.NoNetworksAvailable
|
||||
} else {
|
||||
TokenNetworksState.NetworksAvailable(networks)
|
||||
}
|
||||
|
||||
chartDataProducer.runTransaction {
|
||||
updateLook {
|
||||
it.copy(
|
||||
type = getChartTypeByPercent(percent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateQuotes(newQuotes: TokenQuotes) {
|
||||
quotesStateUpdater.updateQuotes(newQuotes)
|
||||
|
||||
val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval)
|
||||
val priceChangeType = percent.percentChangeType()
|
||||
|
||||
// wait until marker is removed
|
||||
state.first { it.markerSet.not() }
|
||||
|
||||
currentQuotes.value = newQuotes
|
||||
lastUpdatedTimestamp = DateTime.now().millis
|
||||
|
||||
state.update { stateToUpdate ->
|
||||
stateToUpdate.copy(
|
||||
priceText = newQuotes.currentPrice.formatAsPrice(currentAppCurrency.value),
|
||||
priceChangePercentText = newQuotes.getFormattedPercentByInterval(
|
||||
interval = stateToUpdate.selectedInterval,
|
||||
),
|
||||
priceChangeType = priceChangeType,
|
||||
triggerPriceChange = trigger,
|
||||
dateTimeText = getDefaultDateTimeString(stateToUpdate.selectedInterval),
|
||||
)
|
||||
}
|
||||
|
||||
chartDataProducer.runTransaction {
|
||||
updateLook {
|
||||
|
|
@ -489,9 +476,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
launch {
|
||||
while (true) {
|
||||
delay(timeMillis)
|
||||
// Update quotes only when the container bottom sheet is in the expanded state
|
||||
|
||||
// and is visible on the screen
|
||||
// Update quotes only when content visible on the screen
|
||||
isVisibleOnScreen.first { it }
|
||||
|
||||
loadQuotes()
|
||||
|
|
@ -504,7 +489,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
|||
interval = interval,
|
||||
startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval(
|
||||
interval = interval,
|
||||
currentTimestamp = lastUpdatedTimestamp,
|
||||
currentTimestamp = lastUpdatedTimestamp.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ 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.core.ui.utils.BigDecimalFormatter
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.TokenMarketInfo
|
||||
|
|
@ -14,7 +14,7 @@ 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.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Stable
|
||||
|
|
@ -48,7 +48,10 @@ internal class InsightsConverter(
|
|||
onInfoClick(
|
||||
InfoBottomSheetContent(
|
||||
title = resourceReference(R.string.markets_token_details_insights),
|
||||
body = stringReference("//TODO"),
|
||||
body = resourceReference(
|
||||
R.string.markets_insights_info_description_message,
|
||||
wrappedList(value.sourceNetworks.joinToString { it.name }),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -62,74 +65,79 @@ internal class InsightsConverter(
|
|||
liquidityChange: BigDecimal?,
|
||||
buyPressureChange: BigDecimal?,
|
||||
): ImmutableList<InfoPointUM> {
|
||||
return persistentListOf(
|
||||
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),
|
||||
body = resourceReference(R.string.markets_token_details_experienced_buyers_description),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
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),
|
||||
body = resourceReference(R.string.markets_token_details_buy_pressure_description),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
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),
|
||||
body = resourceReference(R.string.markets_token_details_holders_description),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
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),
|
||||
body = resourceReference(R.string.markets_token_details_liquidity_description),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
body = resourceReference(R.string.markets_token_details_liquidity_description),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
).toImmutableList()
|
||||
}
|
||||
|
||||
private fun BigDecimal?.changeType(): InfoPointUM.ChangeType? {
|
||||
private fun BigDecimal.changeType(): InfoPointUM.ChangeType? {
|
||||
return when {
|
||||
this == null -> null
|
||||
this > BigDecimal.ZERO -> InfoPointUM.ChangeType.UP
|
||||
this < BigDecimal.ZERO -> InfoPointUM.ChangeType.DOWN
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun BigDecimal?.convertChange(isFiatValue: Boolean = false): String {
|
||||
if (this == null) return StringsSigns.DASH_SIGN
|
||||
|
||||
private fun BigDecimal.convertChange(isFiatValue: Boolean = false): String {
|
||||
val value = if (isFiatValue) {
|
||||
val currency = appCurrency()
|
||||
BigDecimalFormatter.formatCompactFiatAmount(
|
||||
|
|
@ -141,11 +149,9 @@ internal class InsightsConverter(
|
|||
BigDecimalFormatter.formatCompactAmount(amount = this.abs())
|
||||
}
|
||||
|
||||
val spacing = if (isFiatValue) " " else ""
|
||||
|
||||
return when {
|
||||
this > BigDecimal.ZERO -> StringsSigns.PLUS + spacing + value
|
||||
this < BigDecimal.ZERO -> StringsSigns.MINUS + spacing + value
|
||||
this > BigDecimal.ZERO -> StringsSigns.PLUS + value
|
||||
this < BigDecimal.ZERO -> StringsSigns.MINUS + value
|
||||
this == BigDecimal.ZERO -> value
|
||||
else -> StringsSigns.DASH_SIGN
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,6 @@ import com.tangem.utils.StringsSigns
|
|||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Stable
|
||||
internal class MetricsConverter(
|
||||
|
|
@ -116,19 +113,15 @@ internal class MetricsConverter(
|
|||
|
||||
private fun BigDecimal?.formatAmount(crypto: Boolean = false): String {
|
||||
return if (crypto) {
|
||||
val formatter = NumberFormat.getNumberInstance(Locale.getDefault()).apply {
|
||||
maximumFractionDigits = 0
|
||||
isGroupingUsed = true
|
||||
roundingMode = RoundingMode.HALF_UP
|
||||
}
|
||||
formatter.format(this)
|
||||
BigDecimalFormatter.formatCompactAmount(
|
||||
amount = this ?: BigDecimal.ZERO,
|
||||
)
|
||||
} else {
|
||||
val currency = appCurrency()
|
||||
BigDecimalFormatter.formatFiatAmount(
|
||||
fiatAmount = this,
|
||||
BigDecimalFormatter.formatCompactFiatAmount(
|
||||
amount = this,
|
||||
fiatCurrencyCode = currency.code,
|
||||
fiatCurrencySymbol = currency.symbol,
|
||||
decimals = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,24 +7,23 @@ 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 com.tangem.utils.converter.Converter
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
@Stable
|
||||
internal class PricePerformanceConverter(
|
||||
private val appCurrency: Provider<AppCurrency>,
|
||||
) : Converter<TokenMarketInfo.PricePerformance, PricePerformanceUM> {
|
||||
) {
|
||||
|
||||
override fun convert(value: TokenMarketInfo.PricePerformance): PricePerformanceUM {
|
||||
fun convert(value: TokenMarketInfo.PricePerformance, currentPrice: BigDecimal): PricePerformanceUM {
|
||||
return PricePerformanceUM(
|
||||
h24 = value.day.convert(),
|
||||
month = value.month.convert(),
|
||||
all = value.allTime.convert(),
|
||||
h24 = value.day.convert(currentPrice),
|
||||
month = value.month.convert(currentPrice),
|
||||
all = value.allTime.convert(currentPrice),
|
||||
)
|
||||
}
|
||||
|
||||
private fun TokenMarketInfo.Range?.convert(): PricePerformanceUM.Value {
|
||||
private fun TokenMarketInfo.Range?.convert(currentPrice: BigDecimal): PricePerformanceUM.Value {
|
||||
if (this == null) {
|
||||
return PricePerformanceUM.Value(
|
||||
low = StringsSigns.DASH_SIGN,
|
||||
|
|
@ -36,7 +35,7 @@ internal class PricePerformanceConverter(
|
|||
return PricePerformanceUM.Value(
|
||||
low = low.convert(),
|
||||
high = high.convert(),
|
||||
indicatorFraction = calculateFraction(),
|
||||
indicatorFraction = calculateFraction(currentPrice),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -50,10 +49,15 @@ internal class PricePerformanceConverter(
|
|||
)
|
||||
}
|
||||
|
||||
private fun TokenMarketInfo.Range.calculateFraction(): Float {
|
||||
if (low == null || high == null || low == BigDecimal.ZERO) return 0f
|
||||
return (high!! - low!!).divide(low!!, RoundingMode.HALF_UP)
|
||||
.setScale(2, RoundingMode.HALF_UP)
|
||||
.toFloat().coerceAtMost(1f)
|
||||
private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float {
|
||||
return when {
|
||||
low == null || high == null || high == BigDecimal.ZERO || currentPrice < low -> 0f
|
||||
currentPrice > high || low == high -> 1f
|
||||
else -> {
|
||||
(currentPrice - low!!).divide(high!! - low!!, RoundingMode.HALF_UP)
|
||||
.setScale(2, RoundingMode.HALF_UP)
|
||||
.toFloat().coerceAtMost(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,9 @@ internal class TokenMarketInfoConverter(
|
|||
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.InformationBlocks> {
|
||||
|
||||
private val insightsConverter = InsightsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick)
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
// TODO second markets iteration
|
||||
private val securityScoreConverter = SecurityScoreConverter(onInfoClick = onInfoClick)
|
||||
private val metricsConverter = MetricsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick)
|
||||
private val pricePerformanceConverter = PricePerformanceConverter(appCurrency = appCurrency)
|
||||
|
|
@ -25,9 +28,14 @@ internal class TokenMarketInfoConverter(
|
|||
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks {
|
||||
return MarketsTokenDetailsUM.InformationBlocks(
|
||||
insights = value.insights?.let { insightsConverter.convert(it) },
|
||||
securityScore = securityScoreConverter.convert(Unit),
|
||||
securityScore = null,
|
||||
metrics = value.metrics?.let { metricsConverter.convert(it) },
|
||||
pricePerformance = value.pricePerformance?.let { pricePerformanceConverter.convert(it) },
|
||||
pricePerformance = value.pricePerformance?.let {
|
||||
pricePerformanceConverter.convert(
|
||||
value = it,
|
||||
currentPrice = value.quotes.currentPrice,
|
||||
)
|
||||
},
|
||||
links = value.links?.let { linksConverter.convert(it) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ internal object MarketsDateTimeFormatters {
|
|||
|
||||
private val dateFormatter = DateTimeFormatters.dateDDMMYYYY
|
||||
|
||||
internal fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String {
|
||||
fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String {
|
||||
return when (interval) {
|
||||
PriceChangeInterval.H24 -> { value: BigDecimal ->
|
||||
value.toLong().formatAsDateTime(DateTimeFormatters.timeFormatter)
|
||||
|
|
@ -44,7 +44,7 @@ internal object MarketsDateTimeFormatters {
|
|||
}
|
||||
}
|
||||
|
||||
internal fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference {
|
||||
fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference {
|
||||
return when (interval) {
|
||||
PriceChangeInterval.H24 -> resourceReference(R.string.common_today)
|
||||
PriceChangeInterval.WEEK,
|
||||
|
|
@ -78,10 +78,7 @@ internal object MarketsDateTimeFormatters {
|
|||
}
|
||||
}
|
||||
|
||||
internal fun formatDateByIntervalWithMarker(
|
||||
interval: PriceChangeInterval,
|
||||
markerTimestamp: BigDecimal,
|
||||
): TextReference {
|
||||
fun formatDateByIntervalWithMarker(interval: PriceChangeInterval, markerTimestamp: BigDecimal): TextReference {
|
||||
return when (interval) {
|
||||
PriceChangeInterval.H24,
|
||||
PriceChangeInterval.WEEK,
|
||||
|
|
@ -127,4 +124,14 @@ internal object MarketsDateTimeFormatters {
|
|||
PriceChangeInterval.ALL_TIME -> 0
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultDateTimeString(interval: PriceChangeInterval, currentTimestamp: Long): TextReference {
|
||||
return formatDateByInterval(
|
||||
interval = interval,
|
||||
startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval(
|
||||
interval = interval,
|
||||
currentTimestamp = currentTimestamp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
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.domain.appcurrency.model.AppCurrency
|
||||
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 pricePerformanceConverter = PricePerformanceConverter(currentAppCurrency)
|
||||
|
||||
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.markerSet.not() }
|
||||
|
||||
currentQuotes.value = newQuotes
|
||||
lastUpdatedTimestamp.value = DateTime.now().millis
|
||||
|
||||
state.update { stateToUpdate ->
|
||||
stateToUpdate.copy(
|
||||
priceText = newQuotes.currentPrice.formatAsPrice(currentAppCurrency()),
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
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
|
||||
|
|
@ -193,7 +194,7 @@ private fun TokenPriceText(
|
|||
val fallColor = TangemTheme.colors.text.warning
|
||||
val generalColor = TangemTheme.colors.text.primary1
|
||||
|
||||
val color = remember { Animatable(generalColor) }
|
||||
val color = remember(generalColor) { Animatable(generalColor) }
|
||||
|
||||
EventEffect(triggerPriceChange) {
|
||||
val nextColor = when (it) {
|
||||
|
|
@ -268,12 +269,11 @@ fun PriceChangeInterval.getText(): TextReference {
|
|||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
TangemThemePreview {
|
||||
Content(
|
||||
modifier = Modifier.background(TangemTheme.colors.background.tertiary),
|
||||
addTopBarStatusBarInsets = false,
|
||||
MarketsTokenDetailsContent(
|
||||
state = MarketsTokenDetailsUM(
|
||||
tokenName = "Token Name",
|
||||
priceText = "$0.00000000324",
|
||||
|
|
@ -303,6 +303,7 @@ private fun Preview() {
|
|||
onBackClick = {},
|
||||
backgroundColor = TangemTheme.colors.background.tertiary,
|
||||
portfolioBlock = {},
|
||||
addTopBarStatusBarPadding = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ 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.impl.R
|
||||
import com.tangem.utils.StringsSigns
|
||||
|
||||
@Composable
|
||||
internal fun Description(
|
||||
|
|
@ -33,7 +34,7 @@ internal fun Description(
|
|||
append(description.resolveReference())
|
||||
}
|
||||
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
|
||||
append(" " + stringResource(R.string.common_read_more))
|
||||
append(" " + stringResource(R.string.common_read_more).replace(' ', StringsSigns.NON_BREAKING_SPACE))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) {
|
|||
title = stringResource(id = R.string.markets_token_details_blockchain_site),
|
||||
links = state.blockchainSite,
|
||||
onLinkClick = state.onLinkClick,
|
||||
lastBlock = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -124,15 +124,16 @@ private fun Content(state: PricePerformanceUM.Value, modifier: Modifier = Modifi
|
|||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = state.low,
|
||||
style = TangemTheme.typography.body1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
SpacerW8()
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = state.high,
|
||||
style = TangemTheme.typography.body1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
|
|
|
|||
|
|
@ -126,9 +126,10 @@ private fun LazyListScope.loadingInfoBlocks() {
|
|||
InsightsBlockPlaceholder(modifier = Modifier.blockPaddings())
|
||||
}
|
||||
|
||||
item("securityScore-loading") {
|
||||
SecurityScorePlaceHolder(modifier = Modifier.blockPaddings())
|
||||
}
|
||||
// TODO second markets iteration
|
||||
// item("securityScore-loading") {
|
||||
// SecurityScorePlaceHolder(modifier = Modifier.blockPaddings())
|
||||
// }
|
||||
|
||||
item("metrics-loading") {
|
||||
MetricsBlockPlaceholder(modifier = Modifier.blockPaddings())
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.tangem.features.markets.details.impl.ui.state
|
||||
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
internal data class MetricsUM(
|
||||
val metrics: PersistentList<InfoPointUM>,
|
||||
val metrics: ImmutableList<InfoPointUM>,
|
||||
)
|
||||
|
|
@ -30,7 +30,6 @@ internal fun EntryBottomSheetContent(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val primary = TangemTheme.colors.background.primary
|
||||
val secondary = TangemTheme.colors.background.secondary
|
||||
val backgroundColor = remember { Animatable(primary) }
|
||||
|
||||
LocalMainBottomSheetColor.current.value = backgroundColor.value
|
||||
|
|
@ -73,7 +72,7 @@ private fun BackgroundColorEffects(
|
|||
bottomSheetState: State<BottomSheetState>,
|
||||
) {
|
||||
val primary = TangemTheme.colors.background.primary
|
||||
val secondary = TangemTheme.colors.background.secondary
|
||||
val tertiary = TangemTheme.colors.background.tertiary
|
||||
|
||||
// Order of LaunchedEffects is important here
|
||||
|
||||
|
|
@ -81,7 +80,7 @@ private fun BackgroundColorEffects(
|
|||
when (activeChild) {
|
||||
is MarketsEntryChildFactory.Child.TokenDetails -> {
|
||||
backgroundColor.animateTo(
|
||||
secondary,
|
||||
tertiary,
|
||||
animationSpec = tween(durationMillis = 500),
|
||||
)
|
||||
}
|
||||
|
|
@ -99,7 +98,7 @@ private fun BackgroundColorEffects(
|
|||
when (bottomSheetState.value) {
|
||||
BottomSheetState.EXPANDED -> {
|
||||
backgroundColor.animateTo(
|
||||
secondary,
|
||||
tertiary,
|
||||
animationSpec = tween(durationMillis = 100),
|
||||
)
|
||||
}
|
||||
|
|
@ -113,12 +112,12 @@ private fun BackgroundColorEffects(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(primary, secondary) {
|
||||
LaunchedEffect(primary, tertiary) {
|
||||
if (backgroundColor.isRunning) return@LaunchedEffect
|
||||
|
||||
when (activeChild) {
|
||||
is MarketsEntryChildFactory.Child.TokenDetails -> {
|
||||
backgroundColor.snapTo(secondary)
|
||||
backgroundColor.snapTo(tertiary)
|
||||
}
|
||||
MarketsEntryChildFactory.Child.TokenList -> {
|
||||
backgroundColor.snapTo(primary)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import com.tangem.domain.markets.*
|
|||
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
||||
import com.tangem.features.markets.token.block.impl.ui.state.TokenMarketBlockUM
|
||||
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.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -29,7 +31,7 @@ internal class TokenMarketBlockModel @Inject constructor(
|
|||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val router: Router,
|
||||
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
|
||||
private val getTokenQuotesUseCase: GetTokenQuotesUseCase,
|
||||
private val getTokenQuotesUseCase: GetCurrencyQuotesUseCase,
|
||||
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
|
||||
) : Model() {
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ internal class TokenMarketBlockModel @Inject constructor(
|
|||
)
|
||||
|
||||
private var quotesState: QuotesState? = null
|
||||
private val quotesUpdateJobHolder = JobHolder()
|
||||
|
||||
val state = MutableStateFlow(
|
||||
TokenMarketBlockUM(
|
||||
|
|
@ -65,10 +68,11 @@ internal class TokenMarketBlockModel @Inject constructor(
|
|||
private fun startFetching() {
|
||||
modelScope.launch {
|
||||
getTokenQuotesUseCase(
|
||||
tokenId = params.tokenId,
|
||||
currencyID = params.cryptoCurrencyID,
|
||||
interval = PriceChangeInterval.H24,
|
||||
refresh = true,
|
||||
).collect {
|
||||
it.onRight { res ->
|
||||
it.onSome { res ->
|
||||
quotesState = QuotesState(
|
||||
currentPrice = res.fiatRate,
|
||||
h24Percent = res.priceChange,
|
||||
|
|
@ -90,7 +94,7 @@ internal class TokenMarketBlockModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.saveIn(quotesUpdateJobHolder)
|
||||
|
||||
modelScope.launch(dispatchers.main) {
|
||||
val result = getTokenPriceChartUseCase(
|
||||
|
|
@ -131,7 +135,6 @@ internal class TokenMarketBlockModel @Inject constructor(
|
|||
),
|
||||
)
|
||||
|
||||
// FIXME navigation crash [REDACTED_TASK_KEY]
|
||||
router.push(
|
||||
AppRoute.MarketsTokenDetails(
|
||||
token = tokenParam,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.compose.ui.res.vectorResource
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.common.ui.charts.MarketChartMini
|
||||
import com.tangem.common.ui.charts.state.MarketChartRawData
|
||||
import com.tangem.common.ui.tokens.TokenPriceText
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.SpacerW8
|
||||
import com.tangem.core.ui.components.TextShimmer
|
||||
|
|
@ -83,19 +84,21 @@ private fun LeftSide(
|
|||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
|
||||
) {
|
||||
Text(
|
||||
text = priceText,
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
TokenPriceText(
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
price = priceText,
|
||||
priceChangeType = type,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
|
||||
) {
|
||||
PriceChangeInPercent(
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
valueInPercent = percentText,
|
||||
type = type,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
text = stringResource(id = R.string.wallet_marketprice_block_update_time),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ internal class MarketsListUMStateManager(
|
|||
private fun state(): MarketsListUM = MarketsListUM(
|
||||
list = ListUM.Loading,
|
||||
searchBar = SearchBarUM(
|
||||
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
|
||||
placeholderText = resourceReference(R.string.common_search),
|
||||
query = "",
|
||||
onQueryChange = { searchQuery = it },
|
||||
isActive = false,
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ private fun Preview() {
|
|||
onItemClick = {},
|
||||
),
|
||||
searchBar = SearchBarUM(
|
||||
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
|
||||
placeholderText = resourceReference(R.string.common_search),
|
||||
query = "",
|
||||
onQueryChange = {},
|
||||
isActive = false,
|
||||
|
|
|
|||
|
|
@ -1,185 +1,46 @@
|
|||
package com.tangem.features.markets.tokenlist.impl.ui.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredDraggable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.Button
|
||||
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.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.unit.IntOffset
|
||||
import com.tangem.common.ui.charts.MarketChartMini
|
||||
import com.tangem.common.ui.charts.state.MarketChartLook
|
||||
import com.tangem.common.ui.charts.state.MarketChartRawData
|
||||
import com.tangem.common.ui.tokens.TokenPriceText
|
||||
import com.tangem.core.ui.components.*
|
||||
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.haptic.TangemHapticEffect
|
||||
import com.tangem.core.ui.res.LocalHapticManager
|
||||
import com.tangem.core.ui.res.LocalWindowSize
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.windowsize.WindowSizeType
|
||||
import com.tangem.features.markets.impl.R
|
||||
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
|
||||
import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider
|
||||
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
|
||||
import com.tangem.utils.StringsSigns.MINUS
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
internal enum class DragValue { Start, End }
|
||||
|
||||
const val SWIPE_THRESHOLD_PERCENT = 0.8f
|
||||
const val SWIPE_VELOCITY_THRESHOLD = 20f
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MarketsListItem(
|
||||
model: MarketsListItemUM,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
onSwipeToAction: () -> Unit = {},
|
||||
) {
|
||||
val actionWidth = TangemTheme.dimens.size68
|
||||
val actionWidthPx = with(LocalDensity.current) { actionWidth.toPx() }
|
||||
|
||||
val hapticManager = LocalHapticManager.current
|
||||
|
||||
val anchors = DraggableAnchors {
|
||||
DragValue.Start at 0f
|
||||
DragValue.End at -actionWidthPx
|
||||
}
|
||||
val state = remember {
|
||||
AnchoredDraggableState(
|
||||
initialValue = DragValue.Start,
|
||||
anchors = anchors,
|
||||
positionalThreshold = { it * (1 - SWIPE_THRESHOLD_PERCENT) },
|
||||
velocityThreshold = { SWIPE_VELOCITY_THRESHOLD },
|
||||
animationSpec = tween(easing = FastOutSlowInEasing),
|
||||
confirmValueChange = { it == DragValue.Start },
|
||||
)
|
||||
}
|
||||
val dragInteractionSource = remember { MutableInteractionSource() }
|
||||
val clickInteractionSource = remember { MutableInteractionSource() }
|
||||
val isInDraggedState by dragInteractionSource.collectIsDraggedAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
var actionPerformed = false
|
||||
var releasePerformed = true
|
||||
launch {
|
||||
snapshotFlow { state.offset }
|
||||
.collect {
|
||||
val border = -actionWidthPx * SWIPE_THRESHOLD_PERCENT
|
||||
if (it < border && actionPerformed.not()) {
|
||||
hapticManager.perform(TangemHapticEffect.View.GestureThresholdActivate)
|
||||
actionPerformed = true
|
||||
releasePerformed = false
|
||||
}
|
||||
|
||||
if (it > border) {
|
||||
if (releasePerformed.not()) {
|
||||
hapticManager.perform(TangemHapticEffect.View.GestureThresholdDeactivate)
|
||||
releasePerformed = true
|
||||
}
|
||||
actionPerformed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
snapshotFlow { isInDraggedState }
|
||||
.collect {
|
||||
if (it.not() && actionPerformed) {
|
||||
releasePerformed = true
|
||||
onSwipeToAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(intrinsicSize = IntrinsicSize.Min)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.fillMaxHeight()
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = actionWidthPx.roundToInt() +
|
||||
state
|
||||
.requireOffset()
|
||||
.toInt(),
|
||||
y = 0,
|
||||
)
|
||||
}
|
||||
.width(actionWidth)
|
||||
.background(TangemTheme.colors.control.checked),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.size(TangemTheme.dimens.size28),
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_plus_mini_28),
|
||||
colorFilter = ColorFilter.tint(TangemTheme.colors.icon.primary2),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.clip(RectangleShape)
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = state
|
||||
.requireOffset()
|
||||
.toInt(),
|
||||
y = 0,
|
||||
)
|
||||
}
|
||||
.anchoredDraggable(
|
||||
state = state,
|
||||
orientation = Orientation.Horizontal,
|
||||
interactionSource = dragInteractionSource,
|
||||
)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
interactionSource = clickInteractionSource,
|
||||
indication = rememberRipple(),
|
||||
onClick = onClick,
|
||||
),
|
||||
) {
|
||||
MarketsListItemContent(model = model)
|
||||
}
|
||||
}
|
||||
internal fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
|
||||
MarketsListItemContent(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RectangleShape)
|
||||
.clickable(onClick = onClick),
|
||||
model = model,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -325,38 +186,6 @@ private fun RowScope.TokenMarketCapText(text: String) {
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
|
||||
val growColor = TangemTheme.colors.text.accent
|
||||
val fallColor = TangemTheme.colors.text.warning
|
||||
val generalColor = TangemTheme.colors.text.primary1
|
||||
|
||||
val color = remember { Animatable(generalColor) }
|
||||
|
||||
LaunchedEffect(price) {
|
||||
if (priceChangeType != null) {
|
||||
val nextColor = when (priceChangeType) {
|
||||
PriceChangeType.UP,
|
||||
-> growColor
|
||||
PriceChangeType.DOWN -> fallColor
|
||||
PriceChangeType.NEUTRAL -> return@LaunchedEffect
|
||||
}
|
||||
|
||||
color.animateTo(nextColor, snap())
|
||||
color.animateTo(generalColor, tween(durationMillis = 500))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = price,
|
||||
color = color.value,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.body2,
|
||||
overflow = TextOverflow.Visible,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?) {
|
||||
val chartWidth = TangemTheme.dimens.size56
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ internal class TokenDetailsFragment : ComposeFragment() {
|
|||
val tokenId = id.rawCurrencyId ?: return null // token price is not available
|
||||
|
||||
return TokenMarketBlockComponent.Params(
|
||||
cryptoCurrencyID = id,
|
||||
tokenId = tokenId,
|
||||
tokenName = name,
|
||||
tokenSymbol = symbol,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue