Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-04 19:32:36 +04:00
parent dbdffaa974
commit 8509912947
15 changed files with 402 additions and 57 deletions

View file

@ -171,6 +171,24 @@ internal object TokensDomainModule {
return FetchCardTokenListUseCase(currenciesRepository, networksRepository, quotesRepository, stakingRepository)
}
@Provides
@Singleton
fun providesGetCryptoCurrencyStatusSyncUseCase(
currenciesRepository: CurrenciesRepository,
dispatcherProvider: CoroutineDispatcherProvider,
quotesRepository: QuotesRepository,
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
): GetCryptoCurrencyStatusSyncUseCase {
return GetCryptoCurrencyStatusSyncUseCase(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
dispatchers = dispatcherProvider,
)
}
@Provides
@Singleton
fun provideGetCryptoCurrencyUseCase(currenciesRepository: CurrenciesRepository): GetCryptoCurrencyUseCase {

View file

@ -172,7 +172,7 @@ private fun Modifier.tokenClickable(state: TokenItemState): Modifier = composed
/**
* IMPORTANT! All margins that used between children setup like as children paddings.
*/
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val density = LocalDensity.current
@ -215,20 +215,24 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
is TokenItemState.Loading,
is TokenItemState.Locked,
-> {
fiatAmount = measurables.measureFiatAmount(
state = state,
maxWidth = layoutWidth - icon.width - titleMinWidth,
defaultConstraints = constraints,
)
if (state.fiatAmountState != null) {
fiatAmount = measurables.measureFiatAmount(
state = state,
maxWidth = layoutWidth - icon.width - titleMinWidth,
defaultConstraints = constraints,
)
}
cryptoAmount = measurables.measureCryptoAmount(
state = state,
maxWidth = layoutWidth - icon.width - priceChangeMinWidth,
defaultConstraints = constraints,
)
if (state.subtitle2State != null) {
cryptoAmount = measurables.measureCryptoAmount(
state = state,
maxWidth = layoutWidth - icon.width - priceChangeMinWidth,
defaultConstraints = constraints,
)
}
firstRowRemainingFreeSpace = layoutWidth - icon.width - fiatAmount.width
secondRowRemainingFreeSpace = layoutWidth - icon.width - cryptoAmount.width
firstRowRemainingFreeSpace = layoutWidth - icon.width - (fiatAmount?.width ?: 0)
secondRowRemainingFreeSpace = layoutWidth - icon.width - (cryptoAmount?.width ?: 0)
}
is TokenItemState.Draggable -> {
cryptoAmount = measurables.measureCryptoAmount(

View file

@ -82,7 +82,7 @@ sealed class TokenItemState {
* @property titleState token title
* @property subtitleState token subtitle
* @property fiatAmountState token fiat amount
* @property subtitle2State token crypto amount
* @property subtitle2State token subtitle 2 (example, token crypto amount)
* @property onItemClick callback which will be called when an item is clicked
* @property onItemLongClick callback which will be called when an item is long clicked
*/
@ -91,8 +91,8 @@ sealed class TokenItemState {
override val iconState: CurrencyIconState,
override val titleState: TitleState,
override val subtitleState: SubtitleState,
override val fiatAmountState: FiatAmountState,
override val subtitle2State: Subtitle2State,
override val fiatAmountState: FiatAmountState?,
override val subtitle2State: Subtitle2State?,
override val onItemClick: ((TokenItemState) -> Unit)?,
override val onItemLongClick: ((TokenItemState) -> Unit)?,
) : TokenItemState()

View file

@ -0,0 +1,72 @@
package com.tangem.features.onramp.hottokens
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.jetpack.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.onramp.hottokens.model.HotCryptoModel
import com.tangem.features.onramp.hottokens.portfolio.OnrampAddToPortfolioComponent
import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddToPortfolioBSConfig
import com.tangem.features.onramp.hottokens.ui.HotCrypto
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultHotCryptoComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: HotCryptoComponent.Params,
private val onrampAddToPortfolioComponentFactory: OnrampAddToPortfolioComponent.Factory,
) : HotCryptoComponent, AppComponentContext by context {
private val model: HotCryptoModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state = model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
HotCrypto(state, modifier)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: OnrampAddToPortfolioBSConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent {
return when (config) {
is OnrampAddToPortfolioBSConfig.AddToPortfolio -> {
onrampAddToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = OnrampAddToPortfolioComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = config.cryptoCurrency,
currencyIconState = config.currencyIconState,
onSuccessAdding = config.onSuccessAdding,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
}
}
@AssistedFactory
interface Factory : HotCryptoComponent.Factory {
override fun create(context: AppComponentContext, params: HotCryptoComponent.Params): DefaultHotCryptoComponent
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.onramp.hottokens
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWalletId
/**
* Hot crypto component
*
[REDACTED_AUTHOR]
*/
internal interface HotCryptoComponent : ComposableContentComponent {
/**
* Params
*
* @property userWalletId user wallet id
* @property onTokenClick lambda be invoked when token item is clicked
*/
data class Params(
val userWalletId: UserWalletId,
val onTokenClick: (CryptoCurrencyStatus) -> Unit,
)
interface Factory : ComponentFactory<Params, HotCryptoComponent>
}

View file

@ -0,0 +1,57 @@
package com.tangem.features.onramp.hottokens.converter
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.onramp.model.HotCryptoCurrency
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Token item state converter from [HotCryptoCurrency] to [TokenItemState]
*
* @property appCurrency app currency
* @property onItemClick callback is invoked when item is clicked
*/
internal class HotTokenItemStateConverter(
private val appCurrency: AppCurrency,
private val onItemClick: (TokenItemState, HotCryptoCurrency) -> Unit,
) : Converter<HotCryptoCurrency, TokenItemState> {
override fun convert(value: HotCryptoCurrency): TokenItemState {
return TokenItemState.Content(
id = value.cryptoCurrency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(value.cryptoCurrency),
titleState = TokenItemState.TitleState.Content(text = stringReference(value.cryptoCurrency.name)),
subtitleState = value.getCryptoPriceState(appCurrency),
fiatAmountState = null,
subtitle2State = null,
onItemClick = onItemClick.let { onItemClick -> { onItemClick(it, value) } },
onItemLongClick = null,
)
}
private fun HotCryptoCurrency.getCryptoPriceState(appCurrency: AppCurrency): TokenItemState.SubtitleState {
return TokenItemState.SubtitleState.CryptoPriceContent(
price = fiatRate.getFormattedCryptoPrice(appCurrency),
priceChangePercent = priceChange.format { percent() },
type = priceChange.getPriceChangeType(),
)
}
private fun BigDecimal.getFormattedCryptoPrice(appCurrency: AppCurrency): String {
return format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
}
private fun BigDecimal.getPriceChangeType(): PriceChangeType {
return PriceChangeConverter.fromBigDecimal(value = this)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.onramp.hottokens.di
import com.tangem.features.onramp.hottokens.DefaultHotCryptoComponent
import com.tangem.features.onramp.hottokens.HotCryptoComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface HotCryptoComponentModule {
@Binds
@Singleton
fun bindHotCryptoComponentFactory(factory: DefaultHotCryptoComponent.Factory): HotCryptoComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.hottokens.di
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.hottokens.model.HotCryptoModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(DecomposeComponent::class)
internal interface HotCryptoModelModule {
@Binds
@IntoMap
@ClassKey(HotCryptoModel::class)
fun bindHotCryptoModel(model: HotCryptoModel): Model
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.onramp.hottokens.entity
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import kotlinx.collections.immutable.ImmutableList
/**
* Hot crypto UM
*
* @property items items
*
[REDACTED_AUTHOR]
*/
internal data class HotCryptoUM(val items: ImmutableList<TokensListItemUM>)

View file

@ -0,0 +1,114 @@
package com.tangem.features.onramp.hottokens.model
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.onramp.GetHotCryptoUseCase
import com.tangem.domain.onramp.model.HotCryptoCurrency
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.onramp.hottokens.HotCryptoComponent
import com.tangem.features.onramp.hottokens.converter.HotTokenItemStateConverter
import com.tangem.features.onramp.hottokens.entity.HotCryptoUM
import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddToPortfolioBSConfig
import com.tangem.features.onramp.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* Hot crypto model
*
* @param paramsContainer params container
* @param getHotCryptoUseCase use case for getting hot crypto
* @param getSelectedAppCurrencyUseCase use case for getting selected app currency
* @property getCryptoCurrencyStatusSyncUseCase use case for getting crypto currency status by id
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@ComponentScoped
internal class HotCryptoModel @Inject constructor(
paramsContainer: ParamsContainer,
getHotCryptoUseCase: GetHotCryptoUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
val bottomSheetNavigation: SlotNavigation<OnrampAddToPortfolioBSConfig> = SlotNavigation()
val state: StateFlow<HotCryptoUM> get() = _state
private val _state = MutableStateFlow(value = HotCryptoUM(items = persistentListOf()))
private val params: HotCryptoComponent.Params = paramsContainer.require()
init {
combine(
flow = getSelectedAppCurrencyUseCase().map { it.getOrElse { AppCurrency.Default } },
flow2 = getHotCryptoUseCase(params.userWalletId),
) { appCurrency, currencies ->
HotTokenItemStateConverter(appCurrency = appCurrency, onItemClick = ::onTokenClick)
.convertList(currencies)
.map(TokensListItemUM::Token)
}
.onEach { items ->
_state.update {
HotCryptoUM(
items = buildList {
if (items.isNotEmpty()) {
add(it.getOrCreateGroupTitle())
}
addAll(items)
}
.toImmutableList(),
)
}
}
.launchIn(modelScope)
}
private fun HotCryptoUM.getOrCreateGroupTitle(): TokensListItemUM {
return items.firstOrNull()
.takeIf { it is TokensListItemUM.GroupTitle }
?: TokensListItemUM.GroupTitle(
id = R.string.tokens_list_hot_crypto_header,
text = resourceReference(id = R.string.tokens_list_hot_crypto_header),
)
}
private fun onTokenClick(tokenItemState: TokenItemState, currency: HotCryptoCurrency) {
bottomSheetNavigation.activate(
configuration = OnrampAddToPortfolioBSConfig.AddToPortfolio(
cryptoCurrency = currency.cryptoCurrency,
currencyIconState = tokenItemState.iconState,
onSuccessAdding = ::onSuccessAdding,
),
)
}
private fun onSuccessAdding(id: CryptoCurrency.ID) {
modelScope.launch {
getCryptoCurrencyStatusSyncUseCase(userWalletId = params.userWalletId, cryptoCurrencyId = id)
.onRight {
bottomSheetNavigation.dismiss()
params.onTokenClick(it)
}
.onLeft { Timber.d("Unable to get CryptoCurrencyStatus[$id]: $it") }
}
}
}

View file

@ -26,7 +26,7 @@ interface OnrampAddToPortfolioComponent : ComposableBottomSheetComponent {
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val currencyIconState: CurrencyIconState,
val onSuccessAdding: () -> Unit,
val onSuccessAdding: (CryptoCurrency.ID) -> Unit,
val onDismiss: () -> Unit,
)

View file

@ -8,6 +8,6 @@ internal sealed class OnrampAddToPortfolioBSConfig {
data class AddToPortfolio(
val cryptoCurrency: CryptoCurrency,
val currencyIconState: CurrencyIconState,
val onSuccessAdding: () -> Unit,
val onSuccessAdding: (CryptoCurrency.ID) -> Unit,
) : OnrampAddToPortfolioBSConfig()
}

View file

@ -33,11 +33,11 @@ internal class OnrampAddToPortfolioModel @Inject constructor(
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
) : Model() {
private val params: OnrampAddToPortfolioComponent.Params = paramsContainer.require()
val state: StateFlow<OnrampAddToPortfolioUM> get() = _state
private val _state = MutableStateFlow(value = getInitialState())
private val params: OnrampAddToPortfolioComponent.Params = paramsContainer.require()
private fun getInitialState(): OnrampAddToPortfolioUM {
return OnrampAddToPortfolioUM(
currencyName = params.cryptoCurrency.name,
@ -57,7 +57,7 @@ internal class OnrampAddToPortfolioModel @Inject constructor(
userWalletId = params.userWalletId,
currencies = listOfNotNull(params.cryptoCurrency),
)
.onRight { params.onSuccessAdding() }
.onRight { params.onSuccessAdding(params.cryptoCurrency.id) }
}
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.onramp.hottokens.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.tokenlist.TokenListItem
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.hottokens.entity.HotCryptoUM
/**
* Hot crypto
*
* @param state state
* @param modifier modifier
*/
@Composable
internal fun HotCrypto(state: State<HotCryptoUM>, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
state.value.items.fastForEachIndexed { index, item ->
key(item.id) {
TokenListItem(
state = item,
isBalanceHidden = false, // because token doesn't contain balance info
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.value.items.lastIndex,
addDefaultPadding = false,
backgroundColor = TangemTheme.colors.background.primary,
),
)
}
}
}
}

View file

@ -1,36 +0,0 @@
package com.tangem.feature.swap.di
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
@Module
@InstallIn(ViewModelComponent::class)
class SwapPresentationModule {
@ViewModelScoped
@Provides
fun providesGetCryptoCurrenciesUseCase(
currenciesRepository: CurrenciesRepository,
dispatcherProvider: CoroutineDispatcherProvider,
quotesRepository: QuotesRepository,
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
): GetCryptoCurrencyStatusSyncUseCase {
return GetCryptoCurrencyStatusSyncUseCase(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
dispatchers = dispatcherProvider,
)
}
}