From d2bef0bf1a78da679400daff91c73c000a485708 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Oct 2025 23:05:18 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/YieldSupplyDomainModule.kt | 13 + .../ui/components/text/TextAnimatedCounter.kt | 54 ++++ .../format/bigdecimal/BigDecimalFiatFormat.kt | 18 ++ domain/yield-supply/build.gradle.kts | 4 + .../YieldSupplyGetRewardsBalanceUseCase.kt | 99 ++++++ ...YieldSupplyGetRewardsBalanceUseCaseTest.kt | 301 ++++++++++++++++++ features/tokendetails/impl/build.gradle.kts | 1 + .../tokendetails/TokenDetailsPreviewData.kt | 1 + .../tokendetails/model/TokenDetailsModel.kt | 25 ++ .../state/TokenDetailsBalanceBlockState.kt | 1 + .../TokenDetailsLoadedBalanceConverter.kt | 2 + .../state/factory/TokenDetailsStateFactory.kt | 14 + .../ui/components/TokenDetailsBalanceBlock.kt | 32 +- 13 files changed, 557 insertions(+), 8 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/text/TextAnimatedCounter.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCase.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index ea0ad78e44..e5d58f7331 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -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, + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/text/TextAnimatedCounter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/text/TextAnimatedCounter.kt new file mode 100644 index 0000000000..978dee9beb --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/text/TextAnimatedCounter.kt @@ -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, + ) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt index aef02f2fe3..5c41281344 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt @@ -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 diff --git a/domain/yield-supply/build.gradle.kts b/domain/yield-supply/build.gradle.kts index b2648f08f0..58724d5cd7 100644 --- a/domain/yield-supply/build.gradle.kts +++ b/domain/yield-supply/build.gradle.kts @@ -13,6 +13,9 @@ tasks.withType().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) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCase.kt new file mode 100644 index 0000000000..fb055caf3f --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCase.kt @@ -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 = 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 + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt new file mode 100644 index 0000000000..d592d15e50 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index c02aa188d0..2d9665f967 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -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) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index 66afedfdbf..6ad048f49b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -124,6 +124,7 @@ internal object TokenDetailsPreviewData { selectedBalanceType = BalanceType.ALL, onBalanceSelect = {}, displayCryptoBalance = "966,96 XLM", + displayYeildSupplyCryptoBalance = null, displayFiatBalance = "91,50$", isBalanceSelectorEnabled = true, isBalanceFlickering = false, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index de7ae04637..97f16addf1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -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() @@ -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 = 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( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockState.kt index b650254ea3..a74d80f6b8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockState.kt @@ -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( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt index 22b55017a2..3243636297 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt @@ -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, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index af30b75a43..405d41af19 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -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( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index d38b7d4e5c..9eaa517709 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -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),