Updated on 2026-08-14
This commit is contained in:
parent
3b04938e54
commit
d2bef0bf1a
13 changed files with 557 additions and 8 deletions
|
|
@ -9,6 +9,7 @@ import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
|
|||
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
||||
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
|
||||
import com.tangem.domain.yield.supply.usecase.*
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -176,4 +177,16 @@ internal object YieldSupplyDomainModule {
|
|||
currenciesRepository = currenciesRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideYieldSupplyGetRewardsBalanceUseCase(
|
||||
yieldSupplyRepository: YieldSupplyRepository,
|
||||
dispatcherProvider: CoroutineDispatcherProvider,
|
||||
): YieldSupplyGetRewardsBalanceUseCase {
|
||||
return YieldSupplyGetRewardsBalanceUseCase(
|
||||
yieldSupplyRepository = yieldSupplyRepository,
|
||||
dispatcherProvider = dispatcherProvider,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.tangem.core.ui.components.text
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
@Composable
|
||||
fun TextAnimatedCounter(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = TangemTheme.typography.caption1,
|
||||
) {
|
||||
var oldText by remember {
|
||||
mutableStateOf(text)
|
||||
}
|
||||
SideEffect {
|
||||
oldText = text
|
||||
}
|
||||
Row(modifier = modifier) {
|
||||
for (i in text.indices) {
|
||||
val oldChar = oldText.getOrNull(i)
|
||||
val newChar = text[i]
|
||||
val char = if (oldChar == newChar) {
|
||||
oldText[i]
|
||||
} else {
|
||||
text[i]
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = char,
|
||||
transitionSpec = {
|
||||
slideInVertically { it }.togetherWith(slideOutVertically { -it })
|
||||
},
|
||||
) { char ->
|
||||
Text(
|
||||
text = char.toString(),
|
||||
style = style,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +117,24 @@ fun BigDecimalFiatFormat.price(): BigDecimalFormat = BigDecimalFormat { value ->
|
|||
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats fiat amount with an exact number of fractional digits.
|
||||
*/
|
||||
fun BigDecimalFiatFormat.anyDecimals(decimals: Int): BigDecimalFormat = BigDecimalFormat { value ->
|
||||
val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode)
|
||||
|
||||
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
|
||||
currency = formatterCurrency
|
||||
maximumFractionDigits = decimals
|
||||
minimumFractionDigits = decimals
|
||||
isGroupingUsed = true
|
||||
roundingMode = RoundingMode.HALF_UP
|
||||
}
|
||||
|
||||
formatter.format(value)
|
||||
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
|
||||
}
|
||||
|
||||
// == Helpers ==
|
||||
|
||||
private fun BigDecimal.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ tasks.withType<Test>().configureEach {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Core */
|
||||
implementation(projects.core.ui)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.yieldSupply.models)
|
||||
|
|
@ -23,6 +26,7 @@ dependencies {
|
|||
implementation(projects.domain.blockaid)
|
||||
implementation(projects.domain.quotes)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
|
||||
/** Tandem SDK */
|
||||
implementation(tangemDeps.blockchain)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
package com.tangem.domain.yield.supply.usecase
|
||||
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.anyDecimals
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.ln
|
||||
|
||||
class YieldSupplyGetRewardsBalanceUseCase(
|
||||
private val yieldSupplyRepository: YieldSupplyRepository,
|
||||
private val dispatcherProvider: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
operator fun invoke(status: CryptoCurrencyStatus, appCurrency: AppCurrency): Flow<String> = flow {
|
||||
val amount: BigDecimal? = status.value.amount?.let { amt ->
|
||||
status.value.fiatRate?.let { rate -> amt.multiply(rate) }
|
||||
}
|
||||
if (amount == null) return@flow
|
||||
|
||||
val tokenAddress = (status.currency as? CryptoCurrency.Token)?.contractAddress ?: return@flow
|
||||
val apy = try {
|
||||
val markets = yieldSupplyRepository.getCachedMarkets() ?: yieldSupplyRepository.updateMarkets()
|
||||
markets.firstOrNull { it.tokenAddress.equals(tokenAddress, ignoreCase = true) }?.apy ?: BigDecimal.ZERO
|
||||
} catch (_: Throwable) {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
|
||||
val apyFraction = apy.divide(HUNDRED_BD, SCALE, RoundingMode.HALF_UP)
|
||||
|
||||
if (apyFraction.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return@flow
|
||||
}
|
||||
|
||||
val initialPerTickDelta = amount
|
||||
.multiply(apyFraction)
|
||||
.multiply(TICK_SECONDS_BD)
|
||||
.divide(SECONDS_PER_YEAR_BD, SCALE, RoundingMode.HALF_UP)
|
||||
.abs()
|
||||
|
||||
val minVisibleDecimals = calculateMinVisibleDecimals(initialPerTickDelta)
|
||||
|
||||
var currentBalance: BigDecimal = amount
|
||||
while (true) {
|
||||
emit(
|
||||
currentBalance.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
).anyDecimals(decimals = minVisibleDecimals)
|
||||
},
|
||||
)
|
||||
|
||||
val perTickDelta = currentBalance
|
||||
.multiply(apyFraction)
|
||||
.multiply(TICK_SECONDS_BD)
|
||||
.divide(SECONDS_PER_YEAR_BD, SCALE, RoundingMode.HALF_UP)
|
||||
|
||||
currentBalance = currentBalance.add(perTickDelta)
|
||||
|
||||
delay(TICK_MILLIS)
|
||||
}
|
||||
}.flowOn(dispatcherProvider.default)
|
||||
|
||||
private fun calculateMinVisibleDecimals(perTickDeltaAbs: BigDecimal): Int {
|
||||
if (perTickDeltaAbs <= BigDecimal.ZERO) return MIN_DECIMALS
|
||||
|
||||
val perTickAsDouble = perTickDeltaAbs.toDouble()
|
||||
if (perTickAsDouble.isNaN() || perTickAsDouble.isInfinite()) return MIN_DECIMALS
|
||||
|
||||
val safe = if (perTickAsDouble <= 0.0) EPSILON else perTickAsDouble
|
||||
val raw = ceil(-ln(safe) / LN_10)
|
||||
return raw.toInt().coerceIn(MIN_DECIMALS, MAX_DECIMALS)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TICK_MILLIS: Long = 300
|
||||
private val TICK_SECONDS_BD = BigDecimal("0.3")
|
||||
private val SECONDS_PER_YEAR_BD = BigDecimal("31536000") // 365 * 24 * 60 * 60
|
||||
private val HUNDRED_BD = BigDecimal("100")
|
||||
private const val SCALE = 18
|
||||
|
||||
private const val MIN_DECIMALS = 3
|
||||
private const val MAX_DECIMALS = 8
|
||||
|
||||
private val LN_10 = ln(10.0)
|
||||
private const val EPSILON = 1e-18
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
package com.tangem.domain.yield.supply.usecase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.anyDecimals
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
||||
import com.tangem.domain.yield.supply.models.YieldMarketToken
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.ln
|
||||
|
||||
class YieldSupplyGetRewardsBalanceUseCaseTest {
|
||||
|
||||
private val repository: YieldSupplyRepository = mockk(relaxed = true)
|
||||
|
||||
@Test
|
||||
fun `GIVEN null amount WHEN invoke THEN emit nothing`() = runTest {
|
||||
val token = createToken(createNetwork())
|
||||
val status = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Loading,
|
||||
)
|
||||
|
||||
val dispatcherProvider = testDispatcherProvider(this)
|
||||
val useCase = YieldSupplyGetRewardsBalanceUseCase(repository, dispatcherProvider)
|
||||
val appCurrency = AppCurrency.Default
|
||||
|
||||
val emissions = useCase(status, appCurrency).toList()
|
||||
assertThat(emissions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coin currency WHEN invoke THEN emit nothing`() = runTest {
|
||||
val network = createNetwork()
|
||||
val coin = createNativeCoin(network)
|
||||
val status = CryptoCurrencyStatus(
|
||||
currency = coin,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.ONE,
|
||||
fiatAmount = null,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = null,
|
||||
yieldBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
NetworkAddress.Address(value = "0xabc", type = NetworkAddress.Address.Type.Primary),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
val dispatcherProvider = testDispatcherProvider(this)
|
||||
val useCase = YieldSupplyGetRewardsBalanceUseCase(repository, dispatcherProvider)
|
||||
val appCurrency = AppCurrency.Default
|
||||
|
||||
val emissions = useCase(status, appCurrency).toList()
|
||||
assertThat(emissions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero apy WHEN invoke THEN emit nothing`() = runTest {
|
||||
val network = createNetwork()
|
||||
val token = createToken(network)
|
||||
val amount = BigDecimal("123.45")
|
||||
val status = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = amount,
|
||||
fiatAmount = null,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = null,
|
||||
yieldBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
NetworkAddress.Address(value = "0xabc", type = NetworkAddress.Address.Type.Primary),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
coEvery { repository.getCachedMarkets() } returns listOf(
|
||||
YieldMarketToken(
|
||||
tokenAddress = token.contractAddress,
|
||||
chainId = 1,
|
||||
apy = BigDecimal.ZERO,
|
||||
isActive = true,
|
||||
maxFeeNative = "0",
|
||||
maxFeeUSD = "0",
|
||||
backendId = "id",
|
||||
),
|
||||
)
|
||||
|
||||
val dispatcherProvider = testDispatcherProvider(this)
|
||||
val useCase = YieldSupplyGetRewardsBalanceUseCase(repository, dispatcherProvider)
|
||||
val appCurrency = AppCurrency.Default
|
||||
|
||||
val emissions = useCase(status, appCurrency).toList()
|
||||
assertThat(emissions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN positive apy WHEN invoke THEN emit growing formatted balances`() = runTest {
|
||||
val network = createNetwork()
|
||||
val token = createToken(network)
|
||||
val amount = BigDecimal("100.0")
|
||||
val apy = BigDecimal("12.0") // 12%
|
||||
|
||||
val status = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = amount,
|
||||
fiatAmount = null,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = null,
|
||||
yieldBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
NetworkAddress.Address(value = "0xabc", type = NetworkAddress.Address.Type.Primary),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
coEvery { repository.getCachedMarkets() } returns listOf(
|
||||
YieldMarketToken(
|
||||
tokenAddress = token.contractAddress,
|
||||
chainId = 1,
|
||||
apy = apy,
|
||||
isActive = true,
|
||||
maxFeeNative = "0",
|
||||
maxFeeUSD = "0",
|
||||
backendId = "id",
|
||||
),
|
||||
)
|
||||
|
||||
val dispatcherProvider = testDispatcherProvider(this)
|
||||
val useCase = YieldSupplyGetRewardsBalanceUseCase(repository, dispatcherProvider)
|
||||
val appCurrency = AppCurrency.Default
|
||||
|
||||
val deferred = async { useCase(status, appCurrency).take(3).toList() }
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
advanceTimeBy(300)
|
||||
testScheduler.advanceUntilIdle()
|
||||
advanceTimeBy(300)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
val collected = deferred.await()
|
||||
assertThat(collected).hasSize(3)
|
||||
|
||||
val expectedDecimals = calculateMinVisibleDecimalsForTest(initialPerTickDelta(amount, apy))
|
||||
|
||||
val firstExpected = amount.format { fiat(
|
||||
appCurrency.code,
|
||||
appCurrency.symbol,
|
||||
).anyDecimals(decimals = expectedDecimals) }
|
||||
assertThat(collected[0]).isEqualTo(firstExpected)
|
||||
|
||||
val firstNext = nextBalance(amount, apy)
|
||||
val secondExpected = firstNext.format { fiat(
|
||||
appCurrency.code,
|
||||
appCurrency.symbol,
|
||||
).anyDecimals(decimals = expectedDecimals) }
|
||||
assertThat(collected[1]).isEqualTo(secondExpected)
|
||||
|
||||
val secondNext = nextBalance(firstNext, apy)
|
||||
val thirdExpected = secondNext.format { fiat(
|
||||
appCurrency.code,
|
||||
appCurrency.symbol,
|
||||
).anyDecimals(decimals = expectedDecimals) }
|
||||
assertThat(collected[2]).isEqualTo(thirdExpected)
|
||||
}
|
||||
|
||||
private fun testDispatcherProvider(scope: TestScope): CoroutineDispatcherProvider {
|
||||
val dispatcher: CoroutineDispatcher = StandardTestDispatcher(scope.testScheduler)
|
||||
return object : CoroutineDispatcherProvider {
|
||||
override val main: CoroutineDispatcher = dispatcher
|
||||
override val mainImmediate: CoroutineDispatcher = dispatcher
|
||||
override val io: CoroutineDispatcher = dispatcher
|
||||
override val default: CoroutineDispatcher = dispatcher
|
||||
override val single: CoroutineDispatcher = dispatcher
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNetwork(): Network {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
return Network(
|
||||
id = Network.ID(Network.RawID("polygon"), derivationPath),
|
||||
backendId = "polygon",
|
||||
name = "Polygon",
|
||||
currencySymbol = "MATIC",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.ERC20,
|
||||
hasFiatFeeRate = false,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.ENS,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNativeCoin(network: Network): CryptoCurrency.Coin {
|
||||
val nativeCoinId = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(network.rawId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("polygon-ecosystem-token"),
|
||||
)
|
||||
return CryptoCurrency.Coin(
|
||||
id = nativeCoinId,
|
||||
network = network,
|
||||
name = "Polygon",
|
||||
symbol = "MATIC",
|
||||
decimals = 18,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createToken(network: Network): CryptoCurrency.Token {
|
||||
val tokenId = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(network.rawId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("test-token", "0xContract"),
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = tokenId,
|
||||
network = network,
|
||||
name = "Test Token",
|
||||
symbol = "TT",
|
||||
decimals = 18,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xContract",
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialPerTickDelta(amount: BigDecimal, apy: BigDecimal): BigDecimal {
|
||||
val apyFraction = apy.divide(HUNDRED_BD, SCALE, RoundingMode.HALF_UP)
|
||||
return amount
|
||||
.multiply(apyFraction)
|
||||
.multiply(TICK_SECONDS_BD)
|
||||
.divide(SECONDS_PER_YEAR_BD, SCALE, RoundingMode.HALF_UP)
|
||||
.abs()
|
||||
}
|
||||
|
||||
private fun nextBalance(current: BigDecimal, apy: BigDecimal): BigDecimal {
|
||||
val apyFraction = apy.divide(HUNDRED_BD, SCALE, RoundingMode.HALF_UP)
|
||||
val perTickDelta = current
|
||||
.multiply(apyFraction)
|
||||
.multiply(TICK_SECONDS_BD)
|
||||
.divide(SECONDS_PER_YEAR_BD, SCALE, RoundingMode.HALF_UP)
|
||||
return current.add(perTickDelta)
|
||||
}
|
||||
|
||||
private fun calculateMinVisibleDecimalsForTest(perTickDeltaAbs: BigDecimal): Int {
|
||||
if (perTickDeltaAbs <= BigDecimal.ZERO) return MIN_DECIMALS
|
||||
val perTickAsDouble = perTickDeltaAbs.toDouble()
|
||||
if (perTickAsDouble.isNaN() || perTickAsDouble.isInfinite()) return MIN_DECIMALS
|
||||
val safe = if (perTickAsDouble <= 0.0) EPSILON else perTickAsDouble
|
||||
val raw = ceil(-ln(safe) / LN_10)
|
||||
return raw.toInt().coerceIn(MIN_DECIMALS, MAX_DECIMALS)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val SCALE = 18
|
||||
private val TICK_SECONDS_BD = BigDecimal("0.3")
|
||||
private val SECONDS_PER_YEAR_BD = BigDecimal("31536000")
|
||||
private val HUNDRED_BD = BigDecimal("100")
|
||||
|
||||
private const val MIN_DECIMALS = 3
|
||||
private const val MAX_DECIMALS = 8
|
||||
|
||||
private val LN_10 = ln(10.0)
|
||||
private const val EPSILON = 1e-18
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ dependencies {
|
|||
implementation(projects.domain.txhistory.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.yieldSupply)
|
||||
|
||||
/** Temp dependency to swap domain */
|
||||
implementation(projects.features.swap.domain)
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ internal object TokenDetailsPreviewData {
|
|||
selectedBalanceType = BalanceType.ALL,
|
||||
onBalanceSelect = {},
|
||||
displayCryptoBalance = "966,96 XLM",
|
||||
displayYeildSupplyCryptoBalance = null,
|
||||
displayFiatBalance = "91,50$",
|
||||
isBalanceSelectorEnabled = true,
|
||||
isBalanceFlickering = false,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
|
|||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.demo.IsDemoCardUseCase
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TokenReceiveNotification
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
|
|
@ -74,6 +75,7 @@ import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
|
|||
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetRewardsBalanceUseCase
|
||||
import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionListener
|
||||
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender
|
||||
|
|
@ -151,6 +153,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
private val accountsFeatureToggles: AccountsFeatureToggles,
|
||||
private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
|
||||
private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase,
|
||||
private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase,
|
||||
) : Model(), TokenDetailsClickIntents, YieldSupplyDepositedWarningComponent.ModelCallback {
|
||||
|
||||
private val params = paramsContainer.require<TokenDetailsComponent.Params>()
|
||||
|
|
@ -165,6 +168,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
private val expressTxJobHolder = JobHolder()
|
||||
private val buttonsJobHolder = JobHolder()
|
||||
private val stakingJobHolder = JobHolder()
|
||||
private val yieldSupplyBalanceJobHolder = JobHolder()
|
||||
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
|
||||
|
||||
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
|
||||
|
|
@ -352,6 +356,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
updateButtons(currencyStatus = status)
|
||||
updateWarnings(status)
|
||||
subscribeOnUpdateStakingInfo(status)
|
||||
subscribeOnYieldSupplyBalanceIfActive(status)
|
||||
}
|
||||
currencyStatusAnalyticsSender.send(maybeCurrencyStatus)
|
||||
}
|
||||
|
|
@ -395,6 +400,26 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
.saveIn(expressTxJobHolder)
|
||||
}
|
||||
|
||||
private fun subscribeOnYieldSupplyBalanceIfActive(status: CryptoCurrencyStatus) {
|
||||
if (yieldSupplyFeatureToggles.isYieldSupplyFeatureEnabled &&
|
||||
status.value.yieldSupplyStatus?.isActive == true
|
||||
) {
|
||||
if (yieldSupplyBalanceJobHolder.isActive && status.value.sources.networkSource != StatusSource.ACTUAL) {
|
||||
return
|
||||
}
|
||||
yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value)
|
||||
.onEach { formatted ->
|
||||
internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
|
||||
}
|
||||
.flowOn(dispatchers.main)
|
||||
.launchIn(modelScope)
|
||||
.saveIn(yieldSupplyBalanceJobHolder)
|
||||
} else {
|
||||
yieldSupplyBalanceJobHolder.cancel()
|
||||
internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNetworkToSwapBalance(toCryptoCurrency: CryptoCurrency) {
|
||||
modelScope.launch {
|
||||
updateDelayedCurrencyStatusUseCase(
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ internal sealed class TokenDetailsBalanceBlockState {
|
|||
val isBalanceSelectorEnabled: Boolean,
|
||||
val isBalanceFlickering: Boolean,
|
||||
val yieldSupplyState: TokenDetailsYieldSupplyState = TokenDetailsYieldSupplyState.Empty,
|
||||
val displayYeildSupplyCryptoBalance: String? = null,
|
||||
) : TokenDetailsBalanceBlockState()
|
||||
|
||||
data class Error(
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ internal class TokenDetailsLoadedBalanceConverter(
|
|||
stakingCryptoAmount,
|
||||
currentState.selectedBalanceType,
|
||||
),
|
||||
displayYeildSupplyCryptoBalance = (currentState as? TokenDetailsBalanceBlockState.Content)
|
||||
?.displayYeildSupplyCryptoBalance,
|
||||
balanceSegmentedButtonConfig = currentState.balanceSegmentedButtonConfig,
|
||||
onBalanceSelect = clickIntents::onBalanceSelect,
|
||||
selectedBalanceType = currentState.selectedBalanceType,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase
|
|||
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig
|
||||
import com.tangem.features.tokendetails.impl.R
|
||||
|
|
@ -313,6 +314,19 @@ internal class TokenDetailsStateFactory(
|
|||
return balanceSelectStateConverter.convert(buttonConfig)
|
||||
}
|
||||
|
||||
fun getStateWithUpdatedYieldSupplyDisplayBalance(displayBalance: String?): TokenDetailsState {
|
||||
val state = currentStateProvider()
|
||||
val balanceState = state.tokenBalanceBlockState
|
||||
return state.copy(
|
||||
tokenBalanceBlockState = when (balanceState) {
|
||||
is TokenDetailsBalanceBlockState.Content ->
|
||||
balanceState.copy(displayYeildSupplyCryptoBalance = displayBalance)
|
||||
is TokenDetailsBalanceBlockState.Error -> balanceState
|
||||
is TokenDetailsBalanceBlockState.Loading -> balanceState
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun getStateWithConfirmHideExpressStatus(): TokenDetailsState {
|
||||
return currentStateProvider().copy(
|
||||
dialogConfig = TokenDetailsDialogConfig(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
|
|||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.buttons.HorizontalActionChips
|
||||
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
|
||||
import com.tangem.core.ui.components.text.TextAnimatedCounter
|
||||
import com.tangem.core.ui.components.text.applyBladeBrush
|
||||
import com.tangem.core.ui.extensions.orMaskWithStars
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
|
|
@ -121,14 +122,29 @@ private fun FiatBalance(
|
|||
height = TangemTheme.dimens.size32,
|
||||
),
|
||||
)
|
||||
is TokenDetailsBalanceBlockState.Content -> Text(
|
||||
modifier = modifier,
|
||||
text = state.displayFiatBalance.orMaskWithStars(isBalanceHidden),
|
||||
style = TangemTheme.typography.h2.applyBladeBrush(
|
||||
isEnabled = state.isBalanceFlickering,
|
||||
textColor = TangemTheme.colors.text.primary1,
|
||||
),
|
||||
)
|
||||
is TokenDetailsBalanceBlockState.Content -> if (state.displayYeildSupplyCryptoBalance != null &&
|
||||
!isBalanceHidden
|
||||
) {
|
||||
TextAnimatedCounter(
|
||||
modifier = modifier,
|
||||
text = state.displayYeildSupplyCryptoBalance,
|
||||
style = TangemTheme.typography.h2.applyBladeBrush(
|
||||
isEnabled = state.isBalanceFlickering,
|
||||
textColor = TangemTheme.colors.text.primary1,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = (state.displayYeildSupplyCryptoBalance ?: state.displayFiatBalance).orMaskWithStars(
|
||||
isBalanceHidden,
|
||||
),
|
||||
style = TangemTheme.typography.h2.applyBladeBrush(
|
||||
isEnabled = state.isBalanceFlickering,
|
||||
textColor = TangemTheme.colors.text.primary1,
|
||||
),
|
||||
)
|
||||
}
|
||||
is TokenDetailsBalanceBlockState.Error -> Text(
|
||||
modifier = modifier,
|
||||
text = DASH_SIGN.orMaskWithStars(isBalanceHidden),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue