Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-17 13:01:11 +05:00
parent 396e2cf3e1
commit dcbe3fefb3
17 changed files with 588 additions and 5 deletions

View file

@ -0,0 +1,65 @@
package com.tangem.features.yield.supply.impl.main
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import com.tangem.features.yield.supply.impl.main.model.YieldSupplyModel
import com.tangem.features.yield.supply.impl.main.ui.YieldSupplyBlockContent
import com.tangem.features.yield.supply.impl.subcomponents.active.YieldSupplyActiveEntryComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultYieldSupplyComponent @AssistedInject constructor(
@Assisted private val appComponentContext: AppComponentContext,
@Assisted private val params: YieldSupplyComponent.Params,
) : YieldSupplyComponent, AppComponentContext by appComponentContext {
private val model: YieldSupplyModel = getOrCreateModel(params = params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = { _, context -> bottomSheetChild(context) },
)
@Composable
override fun Content(modifier: Modifier) {
val yieldSupplyUM by model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
YieldSupplyBlockContent(yieldSupplyUM = yieldSupplyUM, modifier = modifier)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent {
return YieldSupplyActiveEntryComponent(
appComponentContext = childByContext(componentContext),
params = YieldSupplyActiveEntryComponent.Params(
userWallet = model.userWallet,
cryptoCurrencyStatusFlow = model.cryptoCurrencyStatusFlow,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory
interface Factory : YieldSupplyComponent.Factory {
override fun create(
context: AppComponentContext,
params: YieldSupplyComponent.Params,
): DefaultYieldSupplyComponent
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.yield.supply.impl.main.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import com.tangem.features.yield.supply.impl.main.DefaultYieldSupplyComponent
import com.tangem.features.yield.supply.impl.main.model.YieldSupplyModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface YieldSupplyBindsModule {
@Binds
@Singleton
fun provideYieldSupplyComponentFactory(impl: DefaultYieldSupplyComponent.Factory): YieldSupplyComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface YieldSupplyModelModule {
@Binds
@IntoMap
@ClassKey(YieldSupplyModel::class)
fun provideYieldSupplyModel(impl: YieldSupplyModel): Model
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.yield.supply.impl.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class YieldSupplyUM {
data class Initial(
val title: TextReference,
val onClick: () -> Unit,
) : YieldSupplyUM()
data object Loading : YieldSupplyUM()
data class Content(
val rewardsBalance: TextReference,
val rewardsApy: TextReference,
val onClick: () -> Unit,
val isAllowedToSpend: Boolean,
) : YieldSupplyUM()
data object Processing : YieldSupplyUM()
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.yield.supply.impl.main.model
interface YieldSupplyClickIntents {
fun onStartEarningClick()
fun onActiveClick()
}

View file

@ -0,0 +1,124 @@
package com.tangem.features.yield.supply.impl.main.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.common.routing.AppRoute.YieldSupplyPromo
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.properties.Delegates
@ModelScoped
internal class YieldSupplyModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val appRouter: AppRouter,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
) : Model(), YieldSupplyClickIntents {
private val params = paramsContainer.require<YieldSupplyComponent.Params>()
val uiState: StateFlow<YieldSupplyUM>
field = MutableStateFlow<YieldSupplyUM>(YieldSupplyUM.Loading)
val bottomSheetNavigation: SlotNavigation<Unit> = SlotNavigation()
private val cryptoCurrency = params.cryptoCurrency
var userWallet: UserWallet by Delegates.notNull()
val cryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>
field = MutableStateFlow(
CryptoCurrencyStatus(
currency = params.cryptoCurrency,
value = CryptoCurrencyStatus.Loading,
),
)
init {
subscribeOnCurrencyStatusUpdates()
}
private fun subscribeOnCurrencyStatusUpdates() {
modelScope.launch {
getUserWalletUseCase(params.userWalletId).fold(
ifRight = { wallet ->
userWallet = wallet
getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet(
userWalletId = params.userWalletId,
currencyId = cryptoCurrency.id,
isSingleWalletWithTokens = false,
).onEach { maybeCryptoCurrency ->
maybeCryptoCurrency.fold(
ifRight = { cryptoCurrencyStatus ->
cryptoCurrencyStatusFlow.update { cryptoCurrencyStatus }
onDataLoaded(cryptoCurrencyStatus)
},
ifLeft = {
// todo error
},
)
}.launchIn(modelScope)
},
ifLeft = {
Timber.w(it.toString())
return@launch
},
)
}
}
override fun onStartEarningClick() {
appRouter.push(
YieldSupplyPromo(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
),
)
}
override fun onActiveClick() {
bottomSheetNavigation.activate(Unit)
}
private fun onDataLoaded(cryptoCurrencyStatus: CryptoCurrencyStatus) {
val yieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus
// todo yield supply add processing state
val yieldSupplyUM = when {
yieldSupplyStatus?.isActive == true ->
YieldSupplyUM.Content(
rewardsBalance = TextReference.EMPTY,
rewardsApy = TextReference.EMPTY,
onClick = ::onActiveClick,
isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend,
)
else -> YieldSupplyUM.Initial(
title = resourceReference(
id = R.string.yield_module_token_details_earn_notification_title,
formatArgs = wrappedList("5.1"),
),
onClick = ::onStartEarningClick,
)
}
uiState.update { yieldSupplyUM }
}
}

View file

@ -0,0 +1,254 @@
package com.tangem.features.yield.supply.impl.main.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
import com.tangem.utils.StringsSigns
@Composable
internal fun YieldSupplyBlockContent(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) {
AnimatedContent(
targetState = yieldSupplyUM,
modifier = modifier,
) { supplyUM ->
when (supplyUM) {
is YieldSupplyUM.Initial -> SupplyInitial(supplyUM)
YieldSupplyUM.Loading -> SupplyLoading()
is YieldSupplyUM.Content -> SupplyContent(supplyUM)
YieldSupplyUM.Processing -> SupplyProcessing()
}
}
}
@Composable
private fun SupplyInitial(supplyUM: YieldSupplyUM.Initial) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.primary)
.padding(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_analytics_up_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
modifier = Modifier
.background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f), CircleShape)
.padding(6.dp)
.size(24.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = supplyUM.title.resolveReference(),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
Text(
text = stringResourceSafe(R.string.yield_module_token_details_earn_notification_description),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
SecondaryButton(
text = stringResourceSafe(R.string.common_learn_more),
onClick = supplyUM.onClick,
size = TangemButtonSize.WideAction,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun SupplyContent(supplyUM: YieldSupplyUM.Content) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.primary)
.clickable(onClick = supplyUM.onClick)
.padding(12.dp),
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f),
) {
Text(
text = stringResourceSafe(
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = supplyUM.rewardsBalance.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
text = StringsSigns.DOT,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.tertiary,
)
Text(
text = supplyUM.rewardsApy.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.tertiary,
)
}
}
SpacerW8()
AnimatedVisibility(supplyUM.isAllowedToSpend.not()) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_alert_triangle_20),
contentDescription = null,
tint = TangemTheme.colors.icon.attention,
)
}
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.size(20.dp),
)
}
}
@Composable
private fun SupplyProcessing() {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.primary)
.padding(12.dp),
) {
Text(
text = stringResourceSafe(
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResourceSafe(
R.string.yield_module_token_details_earn_notification_processing,
),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
)
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = TangemTheme.colors.icon.accent,
strokeWidth = 2.dp,
)
}
}
}
@Composable
private fun SupplyLoading() {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.primary)
.padding(12.dp),
) {
Text(
text = stringResourceSafe(
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
TextShimmer(
style = TangemTheme.typography.subtitle2,
text = stringResourceSafe(
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
),
)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun YieldSupplyBlockContent_Preview(@PreviewParameter(PreviewProvider::class) params: YieldSupplyUM) {
TangemThemePreview {
YieldSupplyBlockContent(params)
}
}
private class PreviewProvider : PreviewParameterProvider<YieldSupplyUM> {
override val values: Sequence<YieldSupplyUM>
get() = sequenceOf(
YieldSupplyUM.Initial(
title = TextReference.Res(
R.string.yield_module_token_details_earn_notification_title,
wrappedList("5.1"),
),
onClick = {},
),
YieldSupplyUM.Content(
rewardsBalance = stringReference("1 USDT"),
rewardsApy = stringReference("5.1 % APY"),
onClick = {},
isAllowedToSpend = false,
),
YieldSupplyUM.Content(
rewardsBalance = stringReference("1 USDT"),
rewardsApy = stringReference("5.1 % APY"),
onClick = {},
isAllowedToSpend = true,
),
YieldSupplyUM.Loading,
YieldSupplyUM.Processing,
)
}
// endregion

View file

@ -3,15 +3,19 @@ package com.tangem.features.yield.supply.impl.promo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.getEmptyComposableBottomSheetComponent
import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent
import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent
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
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -27,7 +31,7 @@ internal class DefaultYieldSupplyPromoComponent @AssistedInject constructor(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = { _, _ -> bottomSheetChild() },
childFactory = { config, componentContext -> bottomSheetChild(config, componentContext) },
)
@Composable
@ -44,7 +48,26 @@ internal class DefaultYieldSupplyPromoComponent @AssistedInject constructor(
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(): ComposableBottomSheetComponent = getEmptyComposableBottomSheetComponent()
private fun bottomSheetChild(
config: YieldSupplyPromoConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
YieldSupplyPromoConfig.Apy -> EmptyComposableBottomSheetComponent
YieldSupplyPromoConfig.Action -> YieldSupplyStartEarningEntryComponent(
appComponentContext = childByContext(componentContext),
params = YieldSupplyStartEarningEntryComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.currency,
onDismiss = { isProcessing ->
if (isProcessing) {
router.pop()
} else {
model.bottomSheetNavigation.dismiss()
}
},
),
)
}
@AssistedFactory
interface Factory : YieldSupplyPromoComponent.Factory {

View file

@ -160,7 +160,9 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
uiState.update { it.copy(isPrimaryButtonEnabled = true) }
},
ifRight = {
params.callback.onTransactionSent()
modelScope.launch {
params.callback.onTransactionSent()
}
},
)
}