Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-06 14:30:09 +05:00
parent d84c61dc58
commit 4c8c06416f
21 changed files with 620 additions and 91 deletions

View file

@ -88,4 +88,24 @@ internal object YieldSupplyDomainModule {
yieldSupplyMarketRepository = yieldSupplyMarketRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetApyUseCase(
yieldSupplyMarketRepository: YieldSupplyMarketRepository,
): YieldSupplyGetApyUseCase {
return YieldSupplyGetApyUseCase(
yieldSupplyMarketRepository = yieldSupplyMarketRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetChartUseCase(
yieldSupplyMarketRepository: YieldSupplyMarketRepository,
): YieldSupplyGetChartUseCase {
return YieldSupplyGetChartUseCase(
yieldSupplyMarketRepository = yieldSupplyMarketRepository,
)
}
}

View file

@ -41,47 +41,32 @@ internal class YieldSupply(
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(ApiEnvironment.DEV),
headers = createHeaders(),
)
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(ApiEnvironment.STAGE),
headers = createHeaders(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(ApiEnvironment.MOCK),
headers = createHeaders(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://yield.tangem.org/",
headers = createHeaders(ApiEnvironment.PROD),
headers = createHeaders(),
)
private fun createHeaders(apiEnvironment: ApiEnvironment) = buildMap {
put(
key = "Authorization",
value = ProviderSuspend {
"Bearer " + environmentConfigStorage.getConfigSync().yieldModuleApiKey
},
)
put(key = "api-key", value = ProviderSuspend { getApiKey(apiEnvironment) })
private fun createHeaders() = buildMap {
put(key = "api-key", value = ProviderSuspend {
environmentConfigStorage.getConfigSync().yieldModuleApiKey.orEmpty()
})
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values)
putAll(from = RequestHeader.AuthenticationHeader(authProvider).values)
}
private fun getApiKey(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.MOCK,
ApiEnvironment.DEV,
ApiEnvironment.DEV_2,
-> environmentConfigStorage.getConfigSync().tangemApiKeyDev
ApiEnvironment.STAGE -> environmentConfigStorage.getConfigSync().tangemApiKeyStage
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().tangemApiKey
} ?: error("No tangem tech api config provided")
}
}

View file

@ -198,8 +198,7 @@ internal class ProdApiConfigsManagerTest {
environment = ApiEnvironment.PROD,
baseUrl = "https://yield.tangem.org/",
headers = mapOf(
"Authorization" to ProviderSuspend { "Bearer " + MockEnvironmentConfigStorage.YIELD_MODULE_KEY },
"api-key" to ProviderSuspend { MockEnvironmentConfigStorage.TANGEM_API_KEY },
"api-key" to ProviderSuspend { MockEnvironmentConfigStorage.YIELD_MODULE_KEY },
"card_id" to ProviderSuspend { APP_CARD_ID },
"card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY },
"version" to ProviderSuspend { VERSION_NAME },

View file

@ -1266,8 +1266,12 @@
<string name="unlock_wallet_description_full">Используйте %s или отсканируйте карту/кольцо, чтобы получить доступ к своему кошельку</string>
<string name="unsupported_wc_version">Соединение не удалось: это dApp использует Wallet Connect версии 1.0, которая не поддерживается. Убедитесь, что dApp поддерживает Wallet Connect версии 2.0 для успешного подключения.</string>
<string name="user_push_notification_agreement_argument_one">Будьте в курсе новых функций и новостей</string>
<string name="user_push_notification_agreement_argument_one_subtitle">Мгновенные уведомления о транзакциях, обменах и важных обновлениях.</string>
<string name="user_push_notification_agreement_argument_one_title">Уведомления о транзакциях</string>
<string name="user_push_notification_agreement_argument_three">Получайте уведомления о входящих транзакциях</string>
<string name="user_push_notification_agreement_argument_two">Узнавайте первым о новых акциях</string>
<string name="user_push_notification_agreement_argument_two_subtitle">Ранний доступ к новым функциям и эксклюзивным предложениям.</string>
<string name="user_push_notification_agreement_argument_two_title">Новые функции и важные новости</string>
<string name="user_push_notification_agreement_header">Хотите использовать Push-уведомления?</string>
<string name="user_wallet_list_add_button">Добавить новый кошелек</string>
<string name="user_wallet_list_delete_prompt">Вы уверены, что хотите забыть этот кошелек?</string>

View file

@ -1743,6 +1743,7 @@
<string name="yield_module_approve_sheet_title">Confirm approval</string>
<string name="yield_module_balance_info_sheet_subtitle">Text about your money in balance [PLACEHOLDER]</string>
<string name="yield_module_balance_info_sheet_title">Your %s is deposited in Aave</string>
<string name="yield_module_chart_loading_error">Unable to load chart...</string>
<string name="yield_module_deposit_error_notification_title">The amount received, %1$s %2$s was not deposited to Aave.</string>
<string name="yield_module_earn_badge">Earn %1$s%%</string>
<string name="yield_module_earn_sheet_available_title">Available</string>
@ -1760,6 +1761,7 @@
<string name="yield_module_fee_policy_sheet_max_fee_title">Maximum fee</string>
<string name="yield_module_fee_policy_sheet_title">Fee policy</string>
<string name="yield_module_high_fee_error">Network fee is too high right now. Waiting until it falls below your limit.</string>
<string name="yield_module_historical_returns">Historical returns</string>
<string name="yield_module_main_view_approve_notification_description">Write description here. In one, two or three lines will be awesome. [PLACEHOLDER]</string>
<string name="yield_module_main_view_approve_notification_title">Some token approve needed</string>
<string name="yield_module_network_fee_unreachable_notification_description">Check your network connection</string>
@ -1790,6 +1792,7 @@
<string name="yield_module_stop_earning">Stop earning</string>
<string name="yield_module_stop_earning_sheet_description">Turning off will withdraw your funds from Aave, return them to %s in your wallet, and stop earning rewards.</string>
<string name="yield_module_stop_earning_sheet_fee_note">The network fee will be deducted from the amount you withdraw.</string>
<string name="yield_module_supply_apr">Supply APR</string>
<string name="yield_module_token_details_earn_notification_apy">APY</string>
<string name="yield_module_token_details_earn_notification_description">Make your money work — earn interest on your balance.</string>
<string name="yield_module_token_details_earn_notification_earning_on_your_balance_title">Earning on your balance</string>

View file

@ -5,9 +5,12 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -82,7 +85,11 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
imageVector = ImageVector.vectorResource(wrappedIcon),
tint = iconColor,
contentDescription = null,
modifier = Modifier.size(16.dp),
modifier = Modifier.size(16.dp).clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = { state.onIconClick?.invoke() },
),
)
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.utils
import java.util.Locale
object TangemLinks {
object TangemBlogUrlBuilder {
private const val RU_LOCALE = "ru"
private const val EN_LOCALE = "en"

View file

@ -0,0 +1,14 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
class YieldSupplyGetApyUseCase(
private val yieldSupplyMarketRepository: YieldSupplyMarketRepository,
) {
suspend operator fun invoke(tokenAddress: String): Either<Throwable, String> = Either.catch {
val apys = yieldSupplyMarketRepository.getCachedMarkets() ?: yieldSupplyMarketRepository.updateMarkets()
apys.first { it.tokenAddress == tokenAddress }.apy.toString()
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
class YieldSupplyGetChartUseCase(
private val yieldSupplyMarketRepository: YieldSupplyMarketRepository,
) {
suspend operator fun invoke(cryptoCurrency: CryptoCurrency.Token): Either<Throwable, YieldSupplyMarketChartData> =
Either.catch {
yieldSupplyMarketRepository.getTokenChart(cryptoCurrency)
}
}

View file

@ -29,7 +29,7 @@ import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorRelo
import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents
import com.tangem.features.send.v2.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents.GasPriceInserter
import com.tangem.features.send.v2.feeselector.model.transformers.*
import com.tangem.utils.TangemLinks
import com.tangem.utils.TangemBlogUrlBuilder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.MutableStateFlow
@ -77,7 +77,7 @@ internal class FeeSelectorModel @Inject constructor(
}
fun onReadMoreClicked() {
urlOpener.openUrl(TangemLinks.FEE_BLOG_LINK)
urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK)
}
private fun initAppCurrency() {

View file

@ -74,7 +74,7 @@ import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalcula
import com.tangem.features.staking.impl.presentation.state.utils.isSingleAction
import com.tangem.features.staking.impl.presentation.state.utils.withStubUnstakeAction
import com.tangem.utils.Provider
import com.tangem.utils.TangemLinks.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.coroutines.*
import com.tangem.utils.extensions.isSingleItem
import com.tangem.utils.extensions.orZero

View file

@ -63,7 +63,7 @@ import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.features.swap.SwapComponent
import com.tangem.utils.Provider
import com.tangem.utils.TangemLinks.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.coroutines.*
import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.NonCancellable

View file

@ -0,0 +1,61 @@
package com.tangem.features.yield.supply.impl.apy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.yield.supply.impl.apy.ui.YieldSupplyApyContent
import com.tangem.features.yield.supply.impl.chart.DefaultYieldSupplyChartComponent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
internal class YieldSupplyApyComponent(
private val appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
val loadingState: StateFlow<Boolean>
field = MutableStateFlow<Boolean>(true)
private val chartComponent = DefaultYieldSupplyChartComponent(
appComponentContext = child("chartComponent"),
params = DefaultYieldSupplyChartComponent.Params(
cryptoCurrency = params.cryptoCurrency as CryptoCurrency.Token,
callback = object : DefaultYieldSupplyChartComponent.ModelCallback {
override fun onStartLoading() {
loadingState.update { true }
}
override fun onSuccessLoad() {
loadingState.update { false }
}
override fun onLoadFail() {
loadingState.update { false }
}
},
),
)
override fun dismiss() {
params.onBackClick()
}
@Composable
override fun BottomSheet() {
val state by loadingState.collectAsState()
YieldSupplyApyContent(
isLoading = state,
onBackClick = params.onBackClick,
chartComponent = chartComponent,
)
}
data class Params(
val cryptoCurrency: CryptoCurrency,
val onBackClick: () -> Unit,
)
}

View file

@ -0,0 +1,195 @@
package com.tangem.features.yield.supply.impl.apy.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH2
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.yield.supply.impl.R
@Suppress("LongMethod")
@Composable
internal fun YieldSupplyApyContent(
isLoading: Boolean,
onBackClick: () -> Unit,
chartComponent: ComposableContentComponent,
) {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onBackClick,
content = TangemBottomSheetConfigContent.Empty,
),
onBack = onBackClick,
containerColor = TangemTheme.colors.background.tertiary,
title = {
TangemModalBottomSheetTitle(
endIconRes = com.tangem.core.ui.R.drawable.ic_close_24,
onEndClick = onBackClick,
)
},
content = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
text = stringResourceSafe(R.string.yield_module_rate_info_sheet_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
SpacerH8()
Text(
text = stringResourceSafe(R.string.yield_module_rate_info_sheet_description),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerH12()
val inlineIconId = "aaveIcon"
Text(
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
text = buildAnnotatedString {
append(stringResourceSafe(R.string.yield_module_rate_info_sheet_powered_by))
append(" ")
appendInlineContent(inlineIconId, "[icon]")
append("Aave")
},
inlineContent = mapOf(
inlineIconId to InlineTextContent(
Placeholder(
width = 16.sp,
height = 16.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
) {
Image(
painter = painterResource(id = R.drawable.img_aave_22),
contentDescription = null,
)
},
),
)
SpacerH24()
ApyChart(
isChartLoading = isLoading,
chartComponent = chartComponent,
modifier = Modifier.padding(horizontal = 12.dp),
)
SpacerH16()
SecondaryButton(
text = stringResourceSafe(R.string.common_got_it),
onClick = onBackClick,
modifier = Modifier
.fillMaxWidth(),
)
}
},
)
}
@Composable
private fun ApyChart(
isChartLoading: Boolean,
chartComponent: ComposableContentComponent,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
if (isChartLoading) {
TextShimmer(
text = stringResourceSafe(R.string.yield_module_historical_returns),
style = TangemTheme.typography.subtitle1,
)
SpacerH2()
TextShimmer(
text = stringResourceSafe(R.string.yield_module_supply_apr),
style = TangemTheme.typography.subtitle2,
)
} else {
Text(
text = stringResourceSafe(R.string.yield_module_historical_returns),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
SpacerH2()
Row(verticalAlignment = Alignment.CenterVertically) {
AccentDot()
SpacerW4()
Text(
text = stringResourceSafe(R.string.yield_module_supply_apr),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
}
SpacerH12()
chartComponent.Content(Modifier)
}
}
@Composable
private fun AccentDot(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.padding(4.dp)
.size(TangemTheme.dimens.size8)
.background(color = TangemTheme.colors.icon.accent, shape = CircleShape),
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun YieldSupplyApyContentContentPreview() {
TangemThemePreview {
YieldSupplyApyContent(
isLoading = false,
onBackClick = {},
chartComponent = ComposableContentComponent.EMPTY,
)
}
}
// endregion

View file

@ -6,6 +6,8 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.yield.supply.impl.chart.model.YieldSupplyChartModel
import com.tangem.features.yield.supply.impl.chart.ui.YieldSupplyChartContent
import dagger.assisted.Assisted
@ -14,13 +16,13 @@ import dagger.assisted.AssistedInject
internal class DefaultYieldSupplyChartComponent @AssistedInject constructor(
@Assisted private val appComponentContext: AppComponentContext,
@Assisted private val params: YieldSupplyChartModel.Params,
) : AppComponentContext by appComponentContext {
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: YieldSupplyChartModel = getOrCreateModel(params = params)
@Composable
fun Content(modifier: Modifier = Modifier) {
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
YieldSupplyChartContent(
state = state,
@ -30,6 +32,17 @@ internal class DefaultYieldSupplyChartComponent @AssistedInject constructor(
@AssistedFactory
internal interface Factory {
fun create(context: AppComponentContext, params: YieldSupplyChartModel.Params): DefaultYieldSupplyChartComponent
fun create(context: AppComponentContext, params: Params): DefaultYieldSupplyChartComponent
}
internal data class Params(
val cryptoCurrency: CryptoCurrency.Token,
val callback: ModelCallback? = null,
)
internal interface ModelCallback {
fun onStartLoading()
fun onSuccessLoad()
fun onLoadFail()
}
}

View file

@ -14,7 +14,10 @@ internal sealed class YieldSupplyChartUM {
data class Error(val onRetry: () -> Unit) : YieldSupplyChartUM()
@Immutable
data class Data(val chartData: YieldSupplyMarketChartDataUM) : YieldSupplyChartUM()
data class Data(
val chartData: YieldSupplyMarketChartDataUM,
val monthLables: ImmutableList<String>,
) : YieldSupplyChartUM()
}
@Immutable
@ -22,16 +25,18 @@ internal data class YieldSupplyMarketChartDataUM(
val y: ImmutableList<Double>,
val x: ImmutableList<Double>,
val avr: Double,
val percentFormat: String,
) {
companion object {
@Suppress("MagicNumber")
fun mock(): YieldSupplyMarketChartDataUM {
// TODO [REDACTED_TASK_KEY] replace mock values with real APY series
val y = listOf(
4.6, 4.0, 4.2, 3.3, 2.5, 5.6, 4.2, 6.7, 3.5, 2.5, 4.5, 3.4,
4.6, 4.0, 4.2, 3.3, 2.5, 5.6, 4.2, 6.7, 3.5, 2.5, 4.5, 3.4,
4.2, 6.7, 3.5, 2.5, 4.5, 3.4,
).toImmutableList()
val x = List(y.size) { 1.0 }.toImmutableList()
return YieldSupplyMarketChartDataUM(y = y, x = x, avr = 5.15)
return YieldSupplyMarketChartDataUM(y = y, x = x, avr = 5.15, "%.1f")
}
}
}

View file

@ -3,34 +3,95 @@ package com.tangem.features.yield.supply.impl.chart.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetChartUseCase
import com.tangem.features.yield.supply.impl.chart.DefaultYieldSupplyChartComponent
import com.tangem.features.yield.supply.impl.chart.entity.YieldSupplyChartUM
import com.tangem.features.yield.supply.impl.chart.entity.YieldSupplyMarketChartDataUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import javax.inject.Inject
@Suppress("UnusedPrivateProperty")
@ModelScoped
internal class YieldSupplyChartModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val yieldSupplyGetChartUseCase: YieldSupplyGetChartUseCase,
) : Model() {
data class Params(
val tokenContractAddress: String,
val onRetry: () -> Unit = {},
)
private val params: Params = paramsContainer.require()
private val params: DefaultYieldSupplyChartComponent.Params = paramsContainer.require()
val uiState: StateFlow<YieldSupplyChartUM>
field = MutableStateFlow<YieldSupplyChartUM>(YieldSupplyChartUM.Loading)
init {
// TODO [REDACTED_TASK_KEY]
val chartData = YieldSupplyMarketChartDataUM.mock()
uiState.update { YieldSupplyChartUM.Data(chartData = chartData) }
loadChart()
}
private fun loadChart() {
modelScope.launch(dispatchers.default) {
params.callback?.onStartLoading()
yieldSupplyGetChartUseCase(params.cryptoCurrency).onRight { chartData ->
if (chartData.y.isEmpty()) {
uiState.update {
YieldSupplyChartUM.Error({
uiState.update { YieldSupplyChartUM.Loading }
loadChart()
})
}
params.callback?.onLoadFail()
} else {
uiState.update {
YieldSupplyChartUM.Data(
chartData = YieldSupplyMarketChartDataUM(
y = chartData.y.toImmutableList(),
x = chartData.x.toImmutableList(),
avr = chartData.avr,
percentFormat = getPercentFormatPattern(chartData),
),
monthLables = lastMonthLabels().toImmutableList(),
)
}
params.callback?.onSuccessLoad()
}
}.onLeft {
uiState.update {
YieldSupplyChartUM.Error({
uiState.update { YieldSupplyChartUM.Loading }
loadChart()
})
}
params.callback?.onLoadFail()
}
}
}
@Suppress("MagicNumber")
private fun getPercentFormatPattern(chartData: YieldSupplyMarketChartData): String {
return when {
chartData.y.all { it < 1.0 } -> "%.1f"
chartData.y.all { it < 0.1 } -> "%.2f"
else -> "%.0f"
}
}
private fun lastMonthLabels(n: Int = MONTH_LABELS_COUNT, locale: Locale = Locale.getDefault()): List<String> {
val calendar = Calendar.getInstance()
val formatter = SimpleDateFormat("MMM", locale)
return (n downTo 1).map {
calendar.add(Calendar.MONTH, -1)
formatter.format(calendar.time)
}.reversed()
}
companion object {
private const val MONTH_LABELS_COUNT = 5
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.yield.supply.impl.chart.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -11,25 +12,31 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottomAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberCustomStartAxis
import com.patrykandpatrick.vico.compose.cartesian.decoration.rememberHorizontalLine
import com.patrykandpatrick.vico.compose.cartesian.fullWidth
import com.patrykandpatrick.vico.compose.cartesian.layer.grouped
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.segmented
import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent
import com.patrykandpatrick.vico.compose.common.shape.dashed
import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout
@ -40,6 +47,9 @@ import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.data.ColumnCartesianLayerModel
import com.patrykandpatrick.vico.core.cartesian.decoration.HorizontalLine
import com.patrykandpatrick.vico.core.cartesian.layer.ColumnCartesianLayer
import com.patrykandpatrick.vico.core.cartesian.layer.ColumnCartesianLayer.MergeMode
import com.patrykandpatrick.vico.core.common.shape.Shape
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
@ -52,6 +62,8 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.yield.supply.impl.chart.entity.YieldSupplyChartUM
import com.tangem.features.yield.supply.impl.chart.entity.YieldSupplyMarketChartDataUM
import kotlinx.collections.immutable.persistentListOf
import java.util.Locale
@Composable
internal fun YieldSupplyChartContent(state: YieldSupplyChartUM, modifier: Modifier = Modifier) {
@ -122,64 +134,187 @@ private fun YieldSupplyChartLoading(modifier: Modifier = Modifier) {
}
}
}
}
}
CircularProgressIndicator(
@Suppress("MagicNumber")
@Composable
private fun YieldSupplyChartData(state: YieldSupplyChartUM.Data, modifier: Modifier = Modifier) {
val model = rememberChartModel(state.chartData)
val columnsLayer = rememberColumnsLayer(accentColor = TangemTheme.colors.text.accent)
val startAxis: VerticalAxis<AxisPosition.Vertical.Start> = rememberStartAxis(
labelColor = TangemTheme.colors.text.tertiary,
percentFormat = state.chartData.percentFormat,
)
val bottomAxis: HorizontalAxis<AxisPosition.Horizontal.Bottom> = rememberBottomAxis(
labelColor = TangemTheme.colors.text.tertiary,
)
val referenceLineThickness = 2.dp
val referenceLine = rememberReferenceLine(
average = state.chartData.avr,
color = TangemTheme.colors.icon.primary1,
thickness = referenceLineThickness,
)
val chart = rememberCartesianChart(
columnsLayer,
startAxis = startAxis,
bottomAxis = bottomAxis,
decorations = listOf(referenceLine),
horizontalLayout = HorizontalLayout.segmented(),
)
val averageLabelTopPadding by rememberAverageLabelTopPadding(state.chartData)
Box(modifier = modifier) {
MonthLabelsRow(labels = state.monthLables, modifier = Modifier.align(Alignment.BottomEnd))
CartesianChartHost(
modifier = Modifier.padding(bottom = 4.dp),
chart = chart,
model = model,
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
scrollState = rememberVicoScrollState(scrollEnabled = false),
)
AverageLabel(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.align(Alignment.Center),
color = TangemTheme.colors.text.tertiary,
.padding(start = 36.dp, top = averageLabelTopPadding)
.height(24.dp),
avr = state.chartData.avr.toString(),
)
}
}
@Composable
private fun YieldSupplyChartData(state: YieldSupplyChartUM.Data, modifier: Modifier = Modifier) {
val yValues = state.chartData.y
val model = CartesianChartModel(ColumnCartesianLayerModel.build { series(yValues) })
private fun rememberChartModel(data: YieldSupplyMarketChartDataUM): CartesianChartModel {
return remember(data.y) {
CartesianChartModel(
ColumnCartesianLayerModel.build {
series(data.y)
},
)
}
}
val layer = rememberColumnCartesianLayer()
@Suppress("MagicNumber")
@Composable
private fun rememberColumnsLayer(accentColor: Color): ColumnCartesianLayer {
val column = rememberLineComponent(
color = accentColor,
thickness = 6.dp,
shape = Shape.rounded(40),
)
return rememberColumnCartesianLayer(
columnProvider = ColumnCartesianLayer.ColumnProvider.series(column),
columnCollectionSpacing = 3.dp,
mergeMode = { MergeMode.grouped() },
)
}
val startAxis: VerticalAxis<AxisPosition.Vertical.Start> = rememberCustomStartAxis(
label = rememberAxisLabelComponent(color = TangemTheme.colors.text.tertiary),
@Composable
private fun rememberStartAxis(labelColor: Color, percentFormat: String): VerticalAxis<AxisPosition.Vertical.Start> {
return rememberCustomStartAxis(
label = rememberAxisLabelComponent(color = labelColor),
valueFormatter = CartesianValueFormatter { value, _, _ ->
val pct = value.toInt()
val pct = String.format(Locale.getDefault(), percentFormat, value)
"$pct%"
},
)
val bottomAxis: HorizontalAxis<AxisPosition.Horizontal.Bottom> = rememberBottomAxis(
label = rememberAxisLabelComponent(color = TangemTheme.colors.text.tertiary),
guideline = null,
tick = null,
line = null,
)
}
val average = yValues.map { it.toDouble() }.average()
@Composable
private fun rememberBottomAxis(labelColor: Color): HorizontalAxis<AxisPosition.Horizontal.Bottom> {
return rememberBottomAxis(
label = rememberAxisLabelComponent(color = labelColor),
guideline = null,
tick = null,
line = null,
valueFormatter = CartesianValueFormatter { _, _, _ ->
""
},
)
}
val referenceLine = rememberHorizontalLine(
@Composable
private fun rememberReferenceLine(average: Double, color: Color, thickness: Dp): HorizontalLine {
return rememberHorizontalLine(
y = { average },
line = rememberLineComponent(
color = TangemTheme.colors.text.tertiary,
thickness = TangemTheme.dimens.size1,
shape = Shape.dashed(Shape.Rectangle, TangemTheme.dimens.size4, TangemTheme.dimens.size4),
color = color,
thickness = thickness,
shape = Shape.dashed(Shape.Pill, 4.dp, 2.dp),
),
)
}
val chart = rememberCartesianChart(
layer,
startAxis = startAxis,
bottomAxis = bottomAxis,
decorations = listOf(referenceLine),
horizontalLayout = HorizontalLayout.fullWidth(),
)
@Composable
private fun rememberAverageLabelTopPadding(data: YieldSupplyMarketChartDataUM): State<Dp> {
return remember(data.y, data.avr) {
derivedStateOf {
val chartHeight = 105.dp
val labelHeight = 24.dp
val referenceLineThickness = 2.dp
val desiredGap = 3.dp
val opticalNudge = 1.dp
val extraPaddingAboveLine = 2.dp
val gapAboveLine = desiredGap + referenceLineThickness / 2 + opticalNudge + extraPaddingAboveLine
val chartContentBottomInset = 10.dp
CartesianChartHost(
modifier = modifier,
chart = chart,
model = model,
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
scrollState = rememberVicoScrollState(scrollEnabled = false),
)
val maxValueInSeries = (data.y.maxOrNull() ?: 0.0).coerceAtLeast(data.avr)
val averageRatio = if (maxValueInSeries == 0.0) {
0f
} else {
(data.avr / maxValueInSeries).coerceIn(0.0, 1.0).toFloat()
}
val computedTopPadding = (chartHeight - chartContentBottomInset) * (1f - averageRatio)
-labelHeight - gapAboveLine
if (computedTopPadding < 2.dp) 2.dp else computedTopPadding
}
}
}
@Composable
private fun MonthLabelsRow(labels: kotlinx.collections.immutable.ImmutableList<String>, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(start = 38.dp, top = 4.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
repeat(labels.size) { index ->
Text(
text = labels[index],
modifier = Modifier
.weight(1f)
.height(16.dp),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable
private fun AverageLabel(avr: String, modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = TangemTheme.colors.icon.primary1,
shape = TangemTheme.shapes.roundedCornersSmall2,
),
) {
Text(
text = stringResourceSafe(R.string.yield_module_rate_info_sheet_chart_average, avr),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary2,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
// region Preview
@ -199,7 +334,10 @@ private class YieldSupplyChartPreviewProvider : PreviewParameterProvider<YieldSu
get() = sequenceOf(
YieldSupplyChartUM.Loading,
YieldSupplyChartUM.Error(onRetry = {}),
YieldSupplyChartUM.Data(chartData = YieldSupplyMarketChartDataUM.mock()),
YieldSupplyChartUM.Data(
chartData = YieldSupplyMarketChartDataUM.mock(),
monthLables = persistentListOf("Jul", "Sep", "Nov", "Feb", "May"),
),
)
}
// endregion

View file

@ -11,8 +11,8 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent
import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent
import com.tangem.features.yield.supply.impl.apy.YieldSupplyApyComponent
import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoModel
import com.tangem.features.yield.supply.impl.promo.ui.YieldSupplyPromoContent
import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSupplyStartEarningEntryComponent
@ -52,7 +52,15 @@ internal class DefaultYieldSupplyPromoComponent @AssistedInject constructor(
config: YieldSupplyPromoConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
YieldSupplyPromoConfig.Apy -> EmptyComposableBottomSheetComponent
YieldSupplyPromoConfig.Apy -> YieldSupplyApyComponent(
appComponentContext = childByContext(componentContext),
params = YieldSupplyApyComponent.Params(
cryptoCurrency = params.currency,
onBackClick = {
model.bottomSheetNavigation.dismiss()
},
),
)
YieldSupplyPromoConfig.Action -> YieldSupplyStartEarningEntryComponent(
appComponentContext = childByContext(componentContext),
params = YieldSupplyStartEarningEntryComponent.Params(

View file

@ -30,7 +30,7 @@ import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSu
import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsUpdateTrigger
import com.tangem.features.yield.supply.impl.subcomponents.notifications.entity.YieldSupplyNotificationData
import com.tangem.utils.StringsSigns.DOT
import com.tangem.utils.TangemLinks
import com.tangem.utils.TangemBlogUrlBuilder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf
@ -101,7 +101,7 @@ internal class YieldSupplyApproveModel @Inject constructor(
}
fun onReadMoreClick() {
urlOpener.openUrl(TangemLinks.FEE_BLOG_LINK)
urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK)
}
override fun onFeeReload() {

View file

@ -27,7 +27,7 @@ import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSu
import com.tangem.features.yield.supply.impl.subcomponents.notifications.entity.YieldSupplyNotificationData
import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent
import com.tangem.features.yield.supply.impl.subcomponents.stopearning.model.transformer.YieldSupplyStopEarningFeeContentTransformer
import com.tangem.utils.TangemLinks
import com.tangem.utils.TangemBlogUrlBuilder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.transformer.update
@ -103,7 +103,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor(
}
fun onReadMoreClick() {
urlOpener.openUrl(TangemLinks.FEE_BLOG_LINK)
urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK)
}
fun onClick() {