diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts index d2009892cf..02f9d36a2e 100644 --- a/features/for-you/impl/build.gradle.kts +++ b/features/for-you/impl/build.gradle.kts @@ -20,20 +20,35 @@ dependencies { /** Features */ implementation(projects.features.forYou.api) + implementation(projects.features.promoBanners.api) + implementation(projects.features.commonFeatures.api) + + /** Domain */ + implementation(projects.domain.common) + implementation(projects.domain.models) + implementation(projects.domain.account.status) + implementation(projects.domain.appCurrency) /** Core */ implementation(projects.core.decompose) implementation(projects.core.ui) implementation(projects.core.configToggles) + implementation(projects.common.ui) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) implementation(deps.compose.foundation) implementation(deps.compose.animation) implementation(deps.lifecycle.compose) implementation(deps.compose.material3) - implementation(deps.compose.ui.tooling) + implementation(deps.kotlin.immutable.collections) /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt index 737af3b002..4177db75fb 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt @@ -5,15 +5,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.getValue 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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeEffectTangem @@ -22,7 +23,12 @@ import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20 import com.tangem.features.foryou.ForYouComponent +import com.tangem.features.foryou.impl.model.ForYouModel +import com.tangem.features.foryou.impl.ui.ForYouContent +import com.tangem.features.promobanners.api.PromoBannersBlockComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -30,8 +36,21 @@ import dagger.assisted.AssistedInject internal class DefaultForYouComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Suppress("UnusedPrivateMember") @Assisted params: Unit, + private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, ) : AppComponentContext by context, ForYouComponent { + private val model: ForYouModel = getOrCreateModel() + + private val promoBannersBlockComponent: PromoBannersBlockComponent by lazy { + promoBannersBlockComponentFactory.create( + context = child("promoBannersBlockComponent"), + params = PromoBannersBlockComponent.Params( + placeholder = PromoBannersBlockComponent.Placeholder.FEED, + isInitiallyVisibleOnScreen = false, + ), + ) + } + @Composable override fun Title(bottomSheetState: State) { TangemTopBar( @@ -39,7 +58,7 @@ internal class DefaultForYouComponent @AssistedInject constructor( type = TangemTopBarType.BottomSheet, startContent = { Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28), + imageVector = Icons.ic_chevron_left_20, contentDescription = null, tint = TangemTheme.colors3.icon.primary, modifier = Modifier @@ -62,7 +81,15 @@ internal class DefaultForYouComponent @AssistedInject constructor( contentPadding: PaddingValues, modifier: Modifier, ) { - Text("FOR YOU") + val uiState by model.uiState.collectAsStateWithLifecycle() + + ForYouContent( + forYouUM = uiState, + bottomSheetState = bottomSheetState, + promoBannersBlockComponent = promoBannersBlockComponent, + contentPadding = contentPadding, + modifier = modifier, + ) } @AssistedFactory diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/di/ForYouFeatureModule.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/di/ForYouFeatureModule.kt index 043984089a..8c916f76d0 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/di/ForYouFeatureModule.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/di/ForYouFeatureModule.kt @@ -1,15 +1,19 @@ package com.tangem.features.foryou.impl.di import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.core.decompose.model.Model import com.tangem.features.foryou.ForYouComponent import com.tangem.features.foryou.ForYouFeatureToggles import com.tangem.features.foryou.impl.DefaultForYouComponent import com.tangem.features.foryou.impl.featuretoggles.DefaultForYouFeatureToggles +import com.tangem.features.foryou.impl.model.ForYouModel import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap import javax.inject.Singleton @Module @@ -30,4 +34,9 @@ internal interface ForYouComponentModule { @Binds @Singleton fun bindForYouComponent(factory: DefaultForYouComponent.Factory): ForYouComponent.Factory + + @Binds + @IntoMap + @ClassKey(ForYouModel::class) + fun bindForYouModel(impl: ForYouModel): Model } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt new file mode 100644 index 0000000000..2e284a4c7f --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt @@ -0,0 +1,39 @@ +package com.tangem.features.foryou.impl.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.ds.tabs.TangemSegmentUM +import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import kotlinx.collections.immutable.ImmutableList + +internal data class ForYouUM( + val walletListUM: WalletListUM, + val portfolioReviewUM: PortfolioReviewUM, +) + +@Immutable +internal sealed interface PortfolioReviewUM { + val tokenList: ImmutableList + + data class Loading( + override val tokenList: ImmutableList, + ) : PortfolioReviewUM + + data class Content( + override val tokenList: ImmutableList, + val periodPickerUM: TangemSegmentedPickerUM, + val assetCount: TextReference, + val topHoldingPercent: TextReference, + val onPeriodClick: (TangemSegmentUM) -> Unit, + ) : PortfolioReviewUM +} + +@Immutable +internal data class ForYouTokenListItemUM( + val tokenRowUM: TangemTokenRowUM, + val tokenList: ImmutableList, + val isExpanded: Boolean, + val isExpandable: Boolean, +) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt new file mode 100644 index 0000000000..2ec2831339 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt @@ -0,0 +1,143 @@ +package com.tangem.features.foryou.impl.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.ds.tabs.TangemSegmentUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetWalletIconUseCase +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM +import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM +import com.tangem.features.foryou.impl.entity.ForYouUM +import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import com.tangem.features.foryou.impl.model.transformer.SetPortfolioReviewTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.orZero +import com.tangem.utils.transformer.update +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@Stable +@ModelScoped +internal class ForYouModel @Inject constructor( + userWalletsListRepository: UserWalletsListRepository, + multiAccountStatusListSupplier: MultiAccountStatusListSupplier, + override val dispatchers: CoroutineDispatcherProvider, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val walletIconUMConverter: WalletIconUMConverter, + private val getWalletIconUseCase: GetWalletIconUseCase, +) : Model() { + + private val locallySelectedWalletId = MutableStateFlow(value = null) + private val expandedAssetIds = MutableStateFlow>(value = emptySet()) + private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + + val uiState: StateFlow + field = MutableStateFlow( + ForYouUM( + walletListUM = WalletListUM(persistentListOf()), + portfolioReviewUM = PortfolioReviewUM.Loading( + tokenList = buildList { + repeat(4) { index -> + add( + ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Loading( + id = index.toString(), + ), + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ), + ) + } + }.toPersistentList(), + ), + ), + ) + + init { + combine( + flow = userWalletsListRepository.userWallets, + flow2 = userWalletsListRepository.selectedUserWallet, + flow3 = multiAccountStatusListSupplier.invokeAsMap(), + flow4 = locallySelectedWalletId, + flow5 = expandedAssetIds, + ) { wallets, globalSelectedWallet, accountStatusList, locallySelected, expanded -> + val selectedId = locallySelected ?: globalSelectedWallet?.walletId + val tabs = wallets.orEmpty().map { wallet -> + WalletTabUM( + text = stringReference(wallet.name), + count = null, + isSelected = wallet.walletId == selectedId, + onClick = { onTabClick(wallet.walletId) }, + deviceIcon = walletIconUMConverter.convert(getWalletIconUseCase(wallet)), + ) + } + + val selectedAccountStatusList = accountStatusList[selectedId] + val currencies = selectedAccountStatusList?.flattenCurrencies().orEmpty() + val loadedBalance = selectedAccountStatusList?.totalFiatBalance as? TotalFiatBalance.Loaded + val totalFiatBalance = loadedBalance?.amount.orZero() + + uiState.update( + SetPortfolioReviewTransformer( + walletListUM = WalletListUM( + items = if (tabs.size != 1) tabs.toPersistentList() else persistentListOf(), + ), + currencies = currencies, + totalFiatBalance = totalFiatBalance, + appCurrency = selectedAppCurrencyFlow.value, + expandedAssetIds = expanded, + expandClick = ::onExpandClick, + onPeriodClick = ::onPeriodClick, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun createSelectedAppCurrencyFlow(): StateFlow { + return getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + } + + private fun onTabClick(walletId: UserWalletId) { + locallySelectedWalletId.value = walletId + } + + private fun onExpandClick(assetId: String) { + expandedAssetIds.update { ids -> + if (assetId in ids) ids - assetId else ids + assetId + } + } + + private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) { + uiState.update { state -> + state.copy( + portfolioReviewUM = (state.portfolioReviewUM as? PortfolioReviewUM.Content)?.copy( + periodPickerUM = state.portfolioReviewUM.periodPickerUM.copy( + initialSelectedItem = tangemSegmentUM, + ), + ) ?: state.portfolioReviewUM, + ) + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt new file mode 100644 index 0000000000..511ececb33 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormatters.kt @@ -0,0 +1,52 @@ +package com.tangem.features.foryou.impl.model.converter + +import com.tangem.core.ui.ds.badge.TangemBadgeColor +import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.badge.TangemBadgeType +import com.tangem.core.ui.ds.badge.TangemBadgeUM +import com.tangem.core.ui.extensions.TextReference +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.models.currency.CryptoCurrencyStatus +import com.tangem.utils.extensions.isZero +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Formatting helpers shared by the For You portfolio-review converters. + * + * Kept null-safe so that non-[CryptoCurrencyStatus.Loaded] states (which carry no fiat amount) degrade + * to a dash / empty instead of throwing. + */ + +/** + * Cross-network grouping key for the portfolio review: the same asset on different networks (e.g. USDC + * on Solana and Ethereum) shares its `rawCurrencyId`, so they group under a single item. Custom tokens + * have no raw id and fall back to their unique currency id, staying in their own group. + */ +internal fun CryptoCurrencyStatus.forYouGroupKey(): String = currency.id.rawCurrencyId?.value ?: currency.id.value + +/** Formats a (possibly null) fiat amount in [appCurrency]; a `null` amount renders as a dash. */ +internal fun BigDecimal?.toForYouFiatText(appCurrency: AppCurrency): TextReference = stringReference( + format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, +) + +/** + * Formats this fiat amount as a share of [totalFiatBalance]. Returns [TextReference.EMPTY] when the + * share cannot be computed (no amount, or a zero total / amount). + */ +internal fun BigDecimal?.toForYouPercentText(totalFiatBalance: BigDecimal): TextReference { + if (this == null || totalFiatBalance.isZero() || isZero()) return TextReference.EMPTY + return stringReference(divide(totalFiatBalance, RoundingMode.HALF_UP).format { percent() }) +} + +// TODO For You: replace this placeholder with the real price-change badge once the design is wired. +internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM( + text = stringReference("Positive"), + size = TangemBadgeSize.X4, + type = TangemBadgeType.Tinted, + color = TangemBadgeColor.Green, +) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt new file mode 100644 index 0000000000..21ba003146 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt @@ -0,0 +1,148 @@ +package com.tangem.features.foryou.impl.model.converter + +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.foryou.impl.R +import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import java.math.BigDecimal + +/** + * Builds the For You portfolio-review list: groups the given currency statuses by asset across networks + * (see [forYouGroupKey]) and maps each group to a [ForYouTokenListItemUM] — an aggregate asset row plus, + * when the asset spans more than one network, its per-network child rows. + * + * The child rows are grouped by network (delegated to [ForYouTokenRowConverter]) so a network appears + * once per asset even if the asset is held on it in several accounts; + * + * Modelled on `TokenListStateConverter` (a list converter delegating to a per-item converter). + */ +@Suppress("LongParameterList") +internal class ForYouTokenListConverter( + private val appCurrency: AppCurrency, + private val totalFiatBalance: BigDecimal, + private val expandedAssetIds: Set, + private val expandClick: (assetId: String) -> Unit, + private val otherAssetCount: Int, + private val otherFiatBalance: BigDecimal, +) : Converter, ImmutableList> { + + private val iconConverter = CryptoCurrencyToIconStateConverter() + private val rowConverter = ForYouTokenRowConverter(appCurrency = appCurrency, totalFiatBalance = totalFiatBalance) + + override fun convert(value: List): ImmutableList { + val assetItems = value + .groupBy { it.forYouGroupKey() } + .map { (assetId, currencies) -> createListItem(assetId, currencies) } + + // Assets beyond the top ones are collapsed into a single non-expandable "Other" row at the bottom. + return if (otherAssetCount > 0) { + assetItems + createOtherItem() + } else { + assetItems + }.toPersistentList() + } + + private fun createListItem(assetId: String, currencies: List): ForYouTokenListItemUM { + // Group the asset's holdings by blockchain (network.id.rawId, derivation-independent) so each + // network appears once even when the asset is held across several accounts/derivations on it, + // summing those balances. Order by balance so the expanded breakdown reads top-down. + val networkGroups = currencies + .groupBy { it.currency.network.id.rawId } + .values + .sortedByDescending { group -> group.sumOf { it.value.fiatAmount.orZero() } } + return ForYouTokenListItemUM( + tokenRowUM = createAssetRow( + assetId = assetId, + currencies = currencies, + networkCount = networkGroups.size, + ), + tokenList = networkGroups.map(rowConverter::convertNetworkGroup).toPersistentList(), + isExpanded = assetId in expandedAssetIds, + isExpandable = true, + ) + } + + private fun createAssetRow( + assetId: String, + currencies: List, + networkCount: Int, + ): TangemTokenRowUM { + if (currencies.all { it.value is CryptoCurrencyStatus.Loading }) { + return TangemTokenRowUM.Loading(id = assetId) + } + + val asset = currencies.first() + val assetFiatBalance = currencies.sumOf { it.value.fiatAmount.orZero() } + + val subtitle = if (networkCount > 1) { + stringReference("$networkCount networks") + } else { + val onlyCryptoCurrency = currencies.firstOrNull()?.currency + val isMain = onlyCryptoCurrency is CryptoCurrency.Coin + when { + isMain -> resourceReference(R.string.common_main_network) + onlyCryptoCurrency != null -> stringReference(onlyCryptoCurrency.network.standardType.name) + else -> TextReference.EMPTY + } + } + + return TangemTokenRowUM.Content( + id = assetId, + headIconUM = TangemIconUM.Currency(iconConverter.convert(asset)), + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(asset.currency.symbol), + badge = forYouPlaceholderBadge(), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = subtitle, + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = assetFiatBalance.toForYouFiatText(appCurrency), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = assetFiatBalance.toForYouPercentText(totalFiatBalance), + ), + onItemClick = { expandClick(assetId) }, + onItemLongClick = null, + ) + } + + private fun createOtherItem(): ForYouTokenListItemUM = ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Content( + id = OTHER_ROW_ID, + headIconUM = TangemIconUM.Currency(CurrencyIconState.Empty()), + titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference("Other")), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference(if (otherAssetCount > 1) "$otherAssetCount assets" else "1 asset"), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = otherFiatBalance.toForYouFiatText(appCurrency), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = otherFiatBalance.toForYouPercentText(totalFiatBalance), + ), + onItemClick = null, + onItemLongClick = null, + ), + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ) + + private companion object { + const val OTHER_ROW_ID = "for_you_other_assets" + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt new file mode 100644 index 0000000000..5a917c91a5 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt @@ -0,0 +1,61 @@ +package com.tangem.features.foryou.impl.model.converter + +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.utils.StringsSigns +import com.tangem.utils.extensions.orZero +import java.math.BigDecimal + +/** + * Builds a single per-network child row of an asset for the For You portfolio review. + * + * The input is all [CryptoCurrencyStatus]es of one asset on the *same* network (the asset may be held in + * several accounts on that network). They are aggregated into one row — the crypto amount and fiat balance + * are the per-network totals — so a network never appears twice within an asset's expanded breakdown. + */ +internal class ForYouTokenRowConverter( + private val appCurrency: AppCurrency, + private val totalFiatBalance: BigDecimal, +) { + + private val iconConverter = CryptoCurrencyToIconStateConverter() + + /** Builds one row for all [statuses] of a single asset on the same network. */ + fun convertNetworkGroup(statuses: List): TangemTokenRowUM { + val representative = statuses.first() + if (statuses.all { it.value is CryptoCurrencyStatus.Loading }) { + return TangemTokenRowUM.Loading(id = representative.currency.id.value) + } + + val currency = representative.currency + val cryptoAmount = statuses.sumOf { it.value.amount.orZero() } + val fiatAmount = statuses.sumOf { it.value.fiatAmount.orZero() } + return TangemTokenRowUM.Content( + id = currency.id.value, + headIconUM = TangemIconUM.Currency(iconConverter.convert(representative)), + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference(currency.symbol), + badge = forYouPlaceholderBadge(), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference( + "${currency.network.name} ${StringsSigns.DOT} ${cryptoAmount.format { crypto( + cryptoCurrency = currency, + ) }}", + ), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content(text = fiatAmount.toForYouFiatText(appCurrency)), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = fiatAmount.toForYouPercentText(totalFiatBalance), + ), + onItemClick = null, + onItemLongClick = null, + ) + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt new file mode 100644 index 0000000000..e9dc792f7c --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt @@ -0,0 +1,110 @@ +package com.tangem.features.foryou.impl.model.transformer + +import com.tangem.core.ui.ds.tabs.TangemSegmentUM +import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM +import com.tangem.core.ui.extensions.stringReference +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.models.currency.CryptoCurrencyStatus +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.foryou.impl.entity.ForYouUM +import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import com.tangem.features.foryou.impl.model.converter.ForYouTokenListConverter +import com.tangem.features.foryou.impl.model.converter.forYouGroupKey +import com.tangem.utils.StringsSigns +import com.tangem.utils.extensions.isZero +import com.tangem.utils.extensions.orZero +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Builds the [ForYouUM] state for the For You screen: the wallet tabs plus the portfolio + * review (asset count, top-holding share, period picker and the grouped token list). + * + * The token list is delegated to [ForYouTokenListConverter]; the period picker selection is carried + * over from the previous state so it is not reset on every balance refresh. + * + * Modelled on `SetTokenListTransformer` (a transformer that rebuilds the state while delegating the + * token-list construction to a dedicated converter). + */ +@Suppress("LongParameterList") +internal class SetPortfolioReviewTransformer( + private val walletListUM: WalletListUM, + private val currencies: List, + private val totalFiatBalance: BigDecimal, + private val appCurrency: AppCurrency, + private val expandedAssetIds: Set, + private val expandClick: (assetId: String) -> Unit, + private val onPeriodClick: (TangemSegmentUM) -> Unit, +) : Transformer { + + override fun transform(prevState: ForYouUM): ForYouUM { + // Drop empty networks, then aggregate the rest into assets (the same token across networks shares + // its forYouGroupKey) and rank assets by their *summed* fiat balance. + val rankedAssets = currencies + .filterNot { it.value.fiatAmount.orZero().isZero() } + .groupBy { it.forYouGroupKey() } + .map { (_, networks) -> networks to networks.sumOf { it.value.fiatAmount.orZero() } } + .sortedByDescending { (_, assetBalance) -> assetBalance } + + // The top assets are shown individually (each flattened back to its networks so the converter can + // regroup them by network); the remaining assets are collapsed into a single "Other" row. + val topAssets = rankedAssets.take(TOP_HOLDINGS_COUNT) + val otherAssets = rankedAssets.drop(TOP_HOLDINGS_COUNT) + val topCurrencies = topAssets.flatMap { (networks, _) -> networks } + val topBalance = topAssets.sumOf { (_, assetBalance) -> assetBalance } + + val tokenList = ForYouTokenListConverter( + appCurrency = appCurrency, + totalFiatBalance = totalFiatBalance, + expandedAssetIds = expandedAssetIds, + expandClick = expandClick, + otherAssetCount = otherAssets.size, + otherFiatBalance = otherAssets.sumOf { (_, assetBalance) -> assetBalance }, + ).convert(topCurrencies) + + return prevState.copy( + walletListUM = walletListUM, + portfolioReviewUM = PortfolioReviewUM.Content( + assetCount = stringReference("${rankedAssets.size} assets"), // TODO For You lokalize + topHoldingPercent = stringReference("Top holding ${topHoldingPercent(topBalance)}"), + periodPickerUM = when (prevState.portfolioReviewUM) { + is PortfolioReviewUM.Content -> prevState.portfolioReviewUM.periodPickerUM + is PortfolioReviewUM.Loading -> createPeriodPicker() + }, + tokenList = tokenList, + onPeriodClick = onPeriodClick, + ), + ) + } + + private fun topHoldingPercent(topBalance: BigDecimal): String { + return if (!totalFiatBalance.isZero() && !topBalance.isZero()) { + topBalance.divide(totalFiatBalance, RoundingMode.HALF_UP).format { percent() } + } else { + StringsSigns.DASH_SIGN + } + } + + private fun createPeriodPicker(): TangemSegmentedPickerUM { + // TODO For you replace with data from backend + val day = TangemSegmentUM(id = "0", title = stringReference("Day")) + return TangemSegmentedPickerUM( + items = persistentListOf( + day, + TangemSegmentUM(id = "1", title = stringReference("Week")), + TangemSegmentUM(id = "2", title = stringReference("Month")), + ), + initialSelectedItem = day, + isFixed = true, + isAltSurface = true, + ) + } + + private companion object { + const val TOP_HOLDINGS_COUNT = 4 + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt new file mode 100644 index 0000000000..baec193e24 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouContent.kt @@ -0,0 +1,116 @@ +package com.tangem.features.foryou.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +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.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM +import com.tangem.features.foryou.impl.entity.ForYouUM +import com.tangem.features.foryou.impl.ui.components.WalletTabsBlock +import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData +import com.tangem.features.promobanners.api.PromoBannersBlockComponent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.distinctUntilChanged + +@Composable +internal fun ForYouContent( + forYouUM: ForYouUM, + bottomSheetState: State, + promoBannersBlockComponent: PromoBannersBlockComponent, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + LaunchedEffect(bottomSheetState, promoBannersBlockComponent) { + snapshotFlow { bottomSheetState.value == BottomSheetState.EXPANDED } + .distinctUntilChanged() + .collect(promoBannersBlockComponent::setVisibleOnScreen) + } + val background = LocalMainBottomSheetColor.current + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = contentPadding.calculateTopPadding()) + .drawBehind { drawRect(background.value) }, + ) { + WalletTabsBlock(walletList = forYouUM.walletListUM) + + SpacerH(12.dp) + + promoBannersBlockComponent.ContentWithPadding( + modifier = Modifier, + horizontalItemPadding = 16.dp, + ) + + ForYouPortfolioReview( + portfolioReviewUM = forYouUM.portfolioReviewUM, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + SpacerH(48.dp) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ForYouContent_Preview(@PreviewParameter(ForYouContentPreviewProvider::class) params: ForYouUM) { + TangemThemePreviewRedesign { + ForYouContent( + forYouUM = params, + bottomSheetState = remember { mutableStateOf(BottomSheetState.EXPANDED) }, + promoBannersBlockComponent = object : PromoBannersBlockComponent { + @Composable + override fun ContentWithPadding(horizontalItemPadding: Dp, modifier: Modifier) { + } + + override fun setVisibleOnScreen(isVisible: Boolean) {} + }, + contentPadding = PaddingValues.Zero, + modifier = Modifier.background(TangemTheme.colors3.bg.primary), + ) + } +} + +private class ForYouContentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ForYouUM( + walletListUM = WalletListUM( + items = persistentListOf( + WalletTabUM( + text = stringReference("Wallet 1"), + count = stringReference("1"), + isSelected = true, + onClick = {}, + deviceIcon = DeviceIconUM.Mobile, + ), + ), + ), + portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent, + ), + ) +} +// endregion \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouPortfolioReview.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouPortfolioReview.kt new file mode 100644 index 0000000000..39470899b6 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouPortfolioReview.kt @@ -0,0 +1,112 @@ +package com.tangem.features.foryou.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.RectangleShimmer +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker +import com.tangem.core.ui.ds2.badge.TangemBadge +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_chevron_down_16 +import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM +import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import com.tangem.features.foryou.impl.ui.components.ForYouMarketChartContent +import com.tangem.features.foryou.impl.ui.components.ForYouPortfolioTokenList +import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Composable +internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Portfolio Review", // TODO For You + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + ) + TangemBadge( + text = stringReference("All accounts"), // TODO For You + variant = TangemBadge.Variant.Solid, + size = TangemBadge.Size.X9, + iconEnd = TangemIconUM.Icon(Icons.ic_chevron_down_16), + ) + } + SpacerH(16.dp) + ForYouMarketChartContent(portfolioReviewUM) + SpacerH(8.dp) + when (portfolioReviewUM) { + is PortfolioReviewUM.Content -> { + TangemSegmentedPicker( + tangemSegmentedPickerUM = portfolioReviewUM.periodPickerUM, + onClick = portfolioReviewUM.onPeriodClick, + ) + } + is PortfolioReviewUM.Loading -> RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + radius = 100.dp, + ) + } + + ForYouPortfolioTokenList(tokenList = portfolioReviewUM.tokenList) + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ForYouPortfolioReview_Review( + @PreviewParameter(ForYouPortfolioReviewPreviewProvider::class) params: PortfolioReviewUM, +) { + TangemThemePreviewRedesign { + ForYouPortfolioReview( + portfolioReviewUM = params, + modifier = Modifier.background(TangemTheme.colors3.bg.primary), + ) + } +} + +private class ForYouPortfolioReviewPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ForYouPortfolioReviewPreviewData.reviewContent, + PortfolioReviewUM.Loading( + tokenList = buildList { + repeat(4) { index -> + add( + ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Loading( + id = index.toString(), + ), + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ), + ) + } + }.toPersistentList(), + ), + ) +} +// endregion \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouMarketChartContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouMarketChartContent.kt new file mode 100644 index 0000000000..f7fe51595f --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouMarketChartContent.kt @@ -0,0 +1,68 @@ +package com.tangem.features.foryou.impl.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.Color.Companion.Cyan +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.foryou.impl.entity.PortfolioReviewUM + +@Composable +internal fun ForYouMarketChartContent(portfolioReviewUM: PortfolioReviewUM) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(TangemTheme.colors3.bg.secondary) + .padding(16.dp), + ) { + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + top = 16.dp, + end = 32.dp, + start = 32.dp, + bottom = 32.dp, + ) + .background(Cyan, CircleShape) + .size(200.dp), + ) + SpacerH(16.dp) + + when (portfolioReviewUM) { + is PortfolioReviewUM.Content -> { + Text( + text = portfolioReviewUM.assetCount.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.secondary, + ) + Text( + text = portfolioReviewUM.topHoldingPercent.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + ) + } + is PortfolioReviewUM.Loading -> { + TextShimmer( + style = TangemTheme.typography3.heading.small, + modifier = Modifier.width(50.dp), + ) + TextShimmer( + style = TangemTheme.typography3.heading.small, + modifier = Modifier.width(75.dp), + ) + } + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt new file mode 100644 index 0000000000..9678d40b4a --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/ForYouPortfolioTokenList.kt @@ -0,0 +1,365 @@ +package com.tangem.features.foryou.impl.ui.components + +import androidx.compose.animation.* +import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.lerp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.common.ui.tokens.SlideInItemVisibility +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.account.toBoxSize +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRow +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_error_20 +import com.tangem.core.ui.utils.ProvideSharedTransitionScope +import com.tangem.core.ui.utils.lazyListItemPosition +import com.tangem.core.ui.utils.sharedBoundsSafely +import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM +import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun ForYouPortfolioTokenList(tokenList: ImmutableList, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + val outerLastIndex = tokenList.lastIndex + tokenList.fastForEachIndexed { index, listItem -> + key(listItem.tokenRowUM.id) { + PortfolioTokenItem(listItem = listItem, index = index, outerLastIndex = outerLastIndex) + } + } + } +} + +@Composable +private fun PortfolioTokenItem(listItem: ForYouTokenListItemUM, index: Int, outerLastIndex: Int) { + PortfolioAssetItem(listItem = listItem, index = index, outerLastIndex = outerLastIndex) + + val lastIndex = listItem.tokenList.lastIndex.inc() + listItem.tokenList.fastForEachIndexed { tokenIndex, item -> + SlideInItemVisibility( + currentIndex = tokenIndex + 1, + lastIndex = lastIndex, + modifier = Modifier + .roundedShapeItemDecoration( + radius = 24.dp, + currentIndex = tokenIndex + 1, + addDefaultPadding = false, + lastIndex = lastIndex, + backgroundColor = TangemTheme.colors3.bg.secondary, + ), + visible = listItem.isExpanded, + ) { + val itemModifier = Modifier + .semantics { lazyListItemPosition = tokenIndex + 1 } + + var position by remember { mutableStateOf(Offset.Zero) } + TangemTokenRow( + tokenRowUM = item, + isBalanceHidden = false, // TODO For You + reorderableState = null, + modifier = itemModifier + .onGloballyPositioned { + position = it.positionInWindow() + } + .conditionalCompose(item.onItemClick != null) { + clickable(onClick = requireNotNull(item.onItemClick)) + }, + ) + } + } +} + +@Suppress("MagicNumber") +@Composable +private fun PortfolioAssetItem(listItem: ForYouTokenListItemUM, index: Int, outerLastIndex: Int) { + val itemBackgroundColor = TangemTheme.colors3.bg.secondary + + ProvideSharedTransitionScope( + modifier = Modifier + .padding(top = 8.dp) + .semantics { lazyListItemPosition = index } + .roundedShapeItemDecoration( + currentIndex = 0, + radius = 24.dp, + addDefaultPadding = false, + lastIndex = portfolioAssetExpandAnimation(listItem = listItem, outerLastIndex = outerLastIndex).value, + backgroundColor = itemBackgroundColor, + ), + ) { + val iconSharedContentState = rememberSharedContentState(key = "icon_${listItem.tokenRowUM.id}") + val titleSharedContentState = rememberSharedContentState(key = "title_${listItem.tokenRowUM.id}") + val boundsTransform = BoundsTransform { _, _ -> tween(250) } + + AnimatedContent( + targetState = listItem.isExpanded, + transitionSpec = { portfolioAssetExpandFadeAnimation() }, + ) { isExpandedWrapped -> + val composables = remember { + SharedTokenRowComposables( + icon = { modifier -> + PortfolioSharedAssetIcon( + listItem = listItem, + isExpandedWrapped = isExpandedWrapped, + itemBackgroundColor = itemBackgroundColor, + modifier = modifier.sharedBoundsSafely( + sharedContentState = iconSharedContentState, + animatedVisibilityScope = this, + boundsTransform = boundsTransform, + ), + ) + }, + title = { modifier -> + PortfolioSharedAssetTitle( + listItem = listItem, + isExpandedWrapped = isExpandedWrapped, + modifier = modifier.sharedBoundsSafely( + sharedContentState = titleSharedContentState, + animatedVisibilityScope = this, + boundsTransform = boundsTransform, + resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart), + ), + ) + }, + ) + } + + if (isExpandedWrapped) { + ForYouPortfolioListHeader( + tokenRowUM = listItem.tokenRowUM, + headComponent = composables.icon, + titleComponent = composables.title, + ) + } else { + TangemTokenRow( + tokenRowUM = listItem.tokenRowUM, + headComponent = composables.icon, + titleComponent = composables.title, + isBalanceHidden = false, // todo For You + reorderableState = null, + ) + } + } + } +} + +@Composable +private fun PortfolioSharedAssetIcon( + listItem: ForYouTokenListItemUM, + isExpandedWrapped: Boolean, + itemBackgroundColor: Color, + modifier: Modifier = Modifier, +) { + val headIcon = listItem.tokenRowUM.headIconUM + + if (headIcon is TangemIconUM.Currency) { + val size = if (isExpandedWrapped) { + AccountIconSize.RedesignExtraSmall + } else { + AccountIconSize.RedesignedDefault + } + val currencyIconState = when (val currencyIconState = headIcon.currencyIconState) { + is CurrencyIconState.CryptoPortfolio.Icon -> currencyIconState.copy(size = size) + is CurrencyIconState.CryptoPortfolio.Letter -> currencyIconState.copy(size = size) + else -> currencyIconState + } + TangemCurrencyIcon( + state = currencyIconState, + shouldDisplayNetwork = false, + modifier = modifier + .size(size.toBoxSize()) + // TODO For You replace with DC components + .drawWithContent { + drawContent() + if (!isExpandedWrapped) { + val offset = 34.dp.toPx() + drawBadge( + color = Color.Red, + containerColor = itemBackgroundColor, + offset = Offset( + x = offset, + y = offset, + ), + size = 3.dp, + padding = 1.dp, + ) + } + }, + ) + } +} + +@Composable +private fun PortfolioSharedAssetTitle( + listItem: ForYouTokenListItemUM, + isExpandedWrapped: Boolean, + modifier: Modifier = Modifier, +) { + val targetAnimationFraction = if (isExpandedWrapped) 0f else 1f + + val animationFraction = animateFloatAsState( + targetValue = targetAnimationFraction, + animationSpec = tween(durationMillis = 350), + ) + + val startStyle = TangemTheme.typography3.subheading.medium + val stopStyle = TangemTheme.typography3.body.medium + + val textStyle by remember(animationFraction.value) { + derivedStateOf { lerp(startStyle, stopStyle, animationFraction.value) } + } + + val resizedTitle = when (val titleUM = listItem.tokenRowUM.titleUM) { + is TangemTokenRowUM.TitleUM.Content -> titleUM.copy( + text = styledStringReference( + titleUM.text.resolveReference(), + { textStyle.toSpanStyle() }, + ), + ) + else -> titleUM + } + + TokenRowTitle( + titleUM = if (isExpandedWrapped) { + (resizedTitle as? TangemTokenRowUM.TitleUM.Content)?.copy(badge = null) ?: resizedTitle + } else { + resizedTitle + }, + modifier = modifier, + ) +} + +@Composable +private fun ForYouPortfolioListHeader( + tokenRowUM: TangemTokenRowUM, + headComponent: @Composable (Modifier) -> Unit, + titleComponent: @Composable (Modifier) -> Unit, +) { + TangemRow( + modifier = Modifier.background(TangemTheme.colors3.bg.secondary), + divider = true, + onClick = tokenRowUM.onItemClick, + verticalAlignment = TangemRowVerticalAlignment.Center, + startSlot = { + headComponent(Modifier) + }, + titleSlot = { + titleComponent(Modifier) + + val topEndUM = tokenRowUM.topEndContentUM + val bottomEndUM = tokenRowUM.bottomEndContentUM + when { + topEndUM is TangemTokenRowUM.EndContentUM.Content && + bottomEndUM is TangemTokenRowUM.EndContentUM.Content -> { + Text( + text = annotatedReference { + appendColored(StringsSigns.DOT, TangemTheme.colors3.icon.tertiary) + appendSpace() + append(topEndUM.text.resolveReference()) + appendSpace() + appendColored(StringsSigns.DOT, TangemTheme.colors3.icon.tertiary) + appendSpace() + appendColored(bottomEndUM.text.resolveReference(), TangemTheme.colors3.text.secondary) + }.resolveAnnotatedReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + topEndUM is TangemTokenRowUM.EndContentUM.Loading || + bottomEndUM is TangemTokenRowUM.EndContentUM.Loading -> { + TextShimmer(style = TangemTheme.typography3.subheading.medium) + } + else -> Unit + } + SpacerWMax() + Icon( + imageVector = Icons.ic_error_20, + tint = TangemTheme.colors3.icon.primary, + contentDescription = null, + ) + }, + ) +} + +private fun DrawScope.drawBadge( + containerColor: Color, + color: Color = TangemColorPalette.Azure, + offset: Offset, + size: Dp = 5.dp, + padding: Dp = 2.dp, +) { + drawCircle( + color = containerColor, + center = offset, + radius = size.toPx(), + ) + drawCircle( + color = color, + center = offset, + radius = (size - padding).toPx(), + ) +} + +@Suppress("MagicNumber") +@Composable +private fun portfolioAssetExpandAnimation(listItem: ForYouTokenListItemUM, outerLastIndex: Int): State { + // Snap immediately on expand; on collapse, hold the current value until all + // child items finish their shrink animation, then snap to fully-rounded shape. + return animateIntAsState( + targetValue = if (listItem.isExpanded) outerLastIndex else 0, + animationSpec = if (listItem.isExpanded) { + snap() + } else { + snap(delayMillis = minOf(50 * maxOf(listItem.tokenList.lastIndex, 0), 250) + 150) + }, + label = "lastIndex", + ) +} + +@Suppress("MagicNumber") +private fun portfolioAssetExpandFadeAnimation(): ContentTransform { + return fadeIn(animationSpec = tween(350, delayMillis = 90)) + .togetherWith(fadeOut(animationSpec = tween(350))) +} + +@Stable +internal class SharedTokenRowComposables( + val title: @Composable (Modifier) -> Unit, + val icon: @Composable (Modifier) -> Unit, +) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/WalletTabsBlock.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/WalletTabsBlock.kt new file mode 100644 index 0000000000..8183e06c8e --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/WalletTabsBlock.kt @@ -0,0 +1,88 @@ +package com.tangem.features.foryou.impl.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.unit.dp +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM + +/** + * Horizontal wallet pill-tab strip for the For You screen. + * + * Faithful replica of the `WalletTabItem` / `walletListItem` reference in + * `features/common-features/impl/.../choosetoken/ui/ChooseTokenScreen.kt` (which is `private`), + * adapted to a standalone composable (For You renders inside a plain scrollable Column, not a LazyListScope). + */ +@Composable +internal fun WalletTabsBlock(walletList: WalletListUM, modifier: Modifier = Modifier) { + if (walletList.items.isEmpty()) return + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(walletList.items) { um -> + WalletTabItem(um) + } + } +} + +@Composable +private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { + val isSelected = state.isSelected + val backgroundColor = if (isSelected) TangemTheme.colors.button.primary else TangemTheme.colors.button.secondary + val buttonTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1 + val countTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.secondary + val countBackground = if (isSelected) { + TangemTheme.colors.button.secondary.copy(alpha = 0.2f) + } else { + TangemTheme.colors.button.primary.copy(alpha = 0.1f) + } + + Row( + modifier = modifier + .clip(RoundedCornerShape(percent = 50)) + .background(backgroundColor) + .clickable(onClick = state.onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.text.resolveReference(), + color = buttonTextColor, + style = TangemTheme.typography2.bodySemibold16, + ) + + val count = state.count + if (count != null) { + Spacer(modifier = Modifier.width(8.dp)) + + Box( + modifier = Modifier + .background(countBackground, shape = CircleShape) + .defaultMinSize(minWidth = 20.dp) + .padding(horizontal = 4.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = count.resolveReference(), + color = countTextColor, + style = TangemTheme.typography.caption1, + ) + } + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouPortfolioReviewPreviewData.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouPortfolioReviewPreviewData.kt new file mode 100644 index 0000000000..e1647620f7 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouPortfolioReviewPreviewData.kt @@ -0,0 +1,144 @@ +package com.tangem.features.foryou.impl.ui.preview + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.badge.TangemBadgeColor +import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.badge.TangemBadgeType +import com.tangem.core.ui.ds.badge.TangemBadgeUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.ds.tabs.TangemSegmentUM +import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM +import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import com.tangem.utils.StringsSigns.DOT +import kotlinx.collections.immutable.persistentListOf + +internal object ForYouPortfolioReviewPreviewData { + + val reviewContent = PortfolioReviewUM.Content( + assetCount = stringReference("5 assets"), + topHoldingPercent = stringReference("Top holding 42%"), + periodPickerUM = TangemSegmentedPickerUM( + items = persistentListOf( + TangemSegmentUM(id = "0", title = stringReference("Day")), + TangemSegmentUM(id = "1", title = stringReference("Week")), + TangemSegmentUM(id = "2", title = stringReference("Month")), + ), + initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")), + isFixed = true, + isAltSurface = true, + ), + onPeriodClick = {}, + tokenList = persistentListOf( + ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Content( + id = "network_0", + headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading), + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference("USDC"), + badge = TangemBadgeUM( + text = stringReference("Positive"), + size = TangemBadgeSize.X4, + type = TangemBadgeType.Tinted, + color = TangemBadgeColor.Green, + ), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference("2 networks"), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("\$5,479"), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("54,8%"), + ), + onItemClick = {}, + onItemLongClick = { _, _ -> }, + ), + tokenList = persistentListOf( + TangemTokenRowUM.Content( + id = "network_0_token_0", + headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading), + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference("USDC"), + badge = TangemBadgeUM( + text = stringReference("Positive"), + size = TangemBadgeSize.X4, + type = TangemBadgeType.Tinted, + color = TangemBadgeColor.Green, + ), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference("Solana $DOT 3,479 USDC"), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("\$3,479"), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("34,7%"), + ), + onItemClick = {}, + onItemLongClick = { _, _ -> }, + ), + TangemTokenRowUM.Content( + id = "network_0_token_1", + headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading), + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference("USDC"), + badge = TangemBadgeUM( + text = stringReference("Positive"), + size = TangemBadgeSize.X4, + type = TangemBadgeType.Tinted, + color = TangemBadgeColor.Green, + ), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference("Ethereum $DOT 2,000 USDC"), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("\$2,000"), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("20,1%"), + ), + onItemClick = {}, + onItemLongClick = { _, _ -> }, + ), + ), + isExpanded = true, + isExpandable = true, + ), + ForYouTokenListItemUM( + tokenRowUM = TangemTokenRowUM.Content( + id = "network_1", + headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading), + titleUM = TangemTokenRowUM.TitleUM.Content( + text = stringReference("Bitcoin"), + badge = TangemBadgeUM( + text = stringReference("Positive"), + size = TangemBadgeSize.X4, + type = TangemBadgeType.Tinted, + color = TangemBadgeColor.Green, + ), + ), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = stringReference("Main network"), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("\$849"), + ), + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content( + text = stringReference("8,49%"), + ), + onItemClick = {}, + onItemLongClick = { _, _ -> }, + ), + tokenList = persistentListOf(), + isExpanded = false, + isExpandable = false, + ), + ), + ) +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt new file mode 100644 index 0000000000..b538927ce3 --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt @@ -0,0 +1,346 @@ +package com.tangem.features.foryou.impl.model + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.TotalFiatBalance +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.wallet.UserWalletIcon +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetWalletIconUseCase +import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass") +internal class ForYouModelTest { + + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk() + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val walletIconUMConverter: WalletIconUMConverter = mockk { + every { convert(any()) } returns DeviceIconUM.Stub(cardsCount = 1) + } + private val getWalletIconUseCase: GetWalletIconUseCase = mockk() + + private var model: ForYouModel? = null + + @BeforeEach + fun setup() { + every { getWalletIconUseCase.invoke(any()) } returns UserWalletIcon.Stub(cardsCount = 1) + // Default: a real, non-empty emission so the model's `getOrElse { Default }` mapping path is + // actually exercised in every test, not bypassed by an empty flow. Individual tests may override. + every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right()) + } + + @AfterEach + fun tearDown() { + model?.onDestroy() + model = null + } + + @Nested + inner class InitialState { + + @Test + fun `GIVEN model created WHEN not yet advanced THEN uiState is Loading`() = runTest { + // Arrange + every { userWalletsListRepository.userWallets } returns MutableStateFlow(null) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf()) + every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right()) + + // Act + val model = createModel(testScope = this) + + // Assert — before advancing, the model exposes skeleton placeholder rows + val loading = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Loading + + assertThat(loading.tokenList).hasSize(4) + assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue() + } + } + + @Nested + inner class ContentState { + + @Test + fun `GIVEN wallets and account statuses emitted WHEN advanced THEN uiState becomes Content with tabs`() = + runTest { + // Arrange + val walletOne = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") + val walletTwo = MockUserWalletFactory.create().copy(walletId = UserWalletId("02"), name = "Wallet 2") + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(walletOne, walletTwo)) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(walletOne) + + val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") + val accountStatusList = createAccountStatusList( + userWalletId = walletOne.walletId, + currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), + totalFiatBalance = BigDecimal("100"), + ) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( + linkedMapOf(walletOne.walletId to accountStatusList), + ) + every { getSelectedAppCurrencyUseCase() } returns flowOf() + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content + assertThat(content.assetCount).isNotNull() + assertThat(model.uiState.value.walletListUM.items).hasSize(2) + assertThat(model.uiState.value.walletListUM.items.map { it.isSelected }).containsExactly(true, false) + } + + @Test + fun `GIVEN exactly one wallet WHEN advanced THEN walletListUM items is empty`() = runTest { + // Arrange + val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet)) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) + + val accountStatusList = createAccountStatusList( + userWalletId = wallet.walletId, + currencies = emptyList(), + totalFiatBalance = BigDecimal.ZERO, + ) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( + linkedMapOf(wallet.walletId to accountStatusList), + ) + every { getSelectedAppCurrencyUseCase() } returns flowOf() + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert — the "tabs.size != 1" rule: a single wallet shows no tabs + assertThat(model.uiState.value.walletListUM.items).isEmpty() + } + } + + @Nested + inner class TabClick { + + @Test + fun `GIVEN two wallets WHEN onTabClick THEN locally selected wallet switches and currencies rederive`() = + runTest { + // Arrange + val walletOne = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") + val walletTwo = MockUserWalletFactory.create().copy(walletId = UserWalletId("02"), name = "Wallet 2") + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(walletOne, walletTwo)) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(walletOne) + + val btc = createCoin(rawCurrencyId = "btc", symbol = "BTC") + val eth = createCoin(rawCurrencyId = "eth", symbol = "ETH") + val statusOne = createAccountStatusList( + userWalletId = walletOne.walletId, + currencies = listOf(createStatus(btc, loadedValue(BigDecimal("100")))), + totalFiatBalance = BigDecimal("100"), + ) + val statusTwo = createAccountStatusList( + userWalletId = walletTwo.walletId, + currencies = listOf(createStatus(eth, loadedValue(BigDecimal("50")))), + totalFiatBalance = BigDecimal("50"), + ) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( + linkedMapOf(walletOne.walletId to statusOne, walletTwo.walletId to statusTwo), + ) + every { getSelectedAppCurrencyUseCase() } returns flowOf() + + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act — click the second wallet's tab + model.uiState.value.walletListUM.items[1].onClick() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.walletListUM.items.map { it.isSelected }).containsExactly( + false, + true, + ).inOrder() + val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content + val assetRow = content.tokenList.single().tokenRowUM as TangemTokenRowUM.Content + val titleUM = assetRow.titleUM as TangemTokenRowUM.TitleUM.Content + assertThat(titleUM.text).isEqualTo(com.tangem.core.ui.extensions.stringReference("ETH")) + } + } + + @Nested + inner class ExpandClick { + + @Test + fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest { + // Arrange + val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet)) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) + + val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") + val accountStatusList = createAccountStatusList( + userWalletId = wallet.walletId, + currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), + totalFiatBalance = BigDecimal("100"), + ) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( + linkedMapOf(wallet.walletId to accountStatusList), + ) + every { getSelectedAppCurrencyUseCase() } returns flowOf() + + val model = createModel(testScope = this) + advanceUntilIdle() + val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content + assertThat(initialContent.tokenList.single().isExpanded).isFalse() + + // Act — click once to expand + val assetRow = initialContent.tokenList.single().tokenRowUM as TangemTokenRowUM.Content + assetRow.onItemClick?.invoke() + advanceUntilIdle() + + // Assert + val expandedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content + assertThat(expandedContent.tokenList.single().isExpanded).isTrue() + + // Act — click again to collapse + val expandedRow = expandedContent.tokenList.single().tokenRowUM as TangemTokenRowUM.Content + expandedRow.onItemClick?.invoke() + advanceUntilIdle() + + // Assert + val collapsedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content + assertThat(collapsedContent.tokenList.single().isExpanded).isFalse() + } + } + + @Nested + inner class PeriodClick { + + @Test + fun `GIVEN Content state WHEN period clicked THEN initialSelectedItem updates without resetting rest`() = + runTest { + // Arrange + val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet)) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) + + val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") + val accountStatusList = createAccountStatusList( + userWalletId = wallet.walletId, + currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), + totalFiatBalance = BigDecimal("100"), + ) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( + linkedMapOf(wallet.walletId to accountStatusList), + ) + every { getSelectedAppCurrencyUseCase() } returns flowOf() + + val model = createModel(testScope = this) + advanceUntilIdle() + val contentBefore = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content + val weekItem = contentBefore.periodPickerUM.items[1] + val assetCountBefore = contentBefore.assetCount + + // Act + contentBefore.onPeriodClick(weekItem) + + // Assert + val contentAfter = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content + assertThat(contentAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) + assertThat(contentAfter.assetCount).isEqualTo(assetCountBefore) + assertThat(contentAfter.tokenList).isEqualTo(contentBefore.tokenList) + } + } + + private fun createModel(testScope: TestScope): ForYouModel { + return ForYouModel( + userWalletsListRepository = userWalletsListRepository, + multiAccountStatusListSupplier = multiAccountStatusListSupplier, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + walletIconUMConverter = walletIconUMConverter, + getWalletIconUseCase = getWalletIconUseCase, + ).also { model = it } + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private fun createAccountStatusList( + userWalletId: UserWalletId, + currencies: List, + totalFiatBalance: BigDecimal, + ): AccountStatusList = mockk { + every { this@mockk.userWalletId } returns userWalletId + every { flattenCurrencies() } returns currencies + every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded( + amount = totalFiatBalance, + source = com.tangem.domain.models.StatusSource.ACTUAL, + ) + } + + private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( + currency = currency, + value = value, + ) + + private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { + every { amount } returns BigDecimal.ONE + every { this@mockk.fiatAmount } returns fiatAmount + every { isError } returns false + } + + private fun createCoin(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin { + val network: Network = mockk { + every { name } returns "Network" + every { isTestnet } returns false + every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) } + } + val currencyId: CryptoCurrency.ID = mockk { + every { value } returns "coin-$rawCurrencyId" + every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) + } + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.symbol } returns symbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns 8 + every { isCustom } returns false + every { iconUrl } returns null + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt new file mode 100644 index 0000000000..9bc177f833 --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt @@ -0,0 +1,163 @@ +package com.tangem.features.foryou.impl.model.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +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.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class ForYouPortfolioFormattersTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + + @Nested + inner class ForYouGroupKey { + + @Test + fun `GIVEN standard currency with raw id WHEN forYouGroupKey THEN returns rawCurrencyId value`() { + // Arrange + val id: CryptoCurrency.ID = mockk { + every { rawCurrencyId } returns CryptoCurrency.RawID("bitcoin") + every { value } returns "coin-id-value" + } + val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id } + val status = createStatus(currency) + + // Act + val result = status.forYouGroupKey() + + // Assert + assertThat(result).isEqualTo("bitcoin") + } + + @Test + fun `GIVEN custom token with no raw id WHEN forYouGroupKey THEN falls back to currency id value`() { + // Arrange + val id: CryptoCurrency.ID = mockk { + every { rawCurrencyId } returns null + every { value } returns "custom-currency-id" + } + val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id } + val status = createStatus(currency) + + // Act + val result = status.forYouGroupKey() + + // Assert + assertThat(result).isEqualTo("custom-currency-id") + } + + private fun createStatus(currency: CryptoCurrency): CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + } + + @Nested + inner class ToForYouFiatText { + + @Test + fun `GIVEN a fiat amount WHEN toForYouFiatText THEN delegates to fiat formatting`() { + // Arrange + val amount = BigDecimal("1234.5") + + // Act + val result = amount.toForYouFiatText(appCurrency) + + // Assert + val expected = stringReference( + amount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `GIVEN null amount WHEN toForYouFiatText THEN renders dash text`() { + // Arrange + val amount: BigDecimal? = null + + // Act + val result = amount.toForYouFiatText(appCurrency) + + // Assert + val expected = stringReference( + amount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, + ) + assertThat(result).isEqualTo(expected) + } + } + + @Nested + inner class ToForYouPercentText { + + @Test + fun `GIVEN null amount WHEN toForYouPercentText THEN returns EMPTY`() { + // Arrange + val amount: BigDecimal? = null + + // Act + val result = amount.toForYouPercentText(BigDecimal("100")) + + // Assert + assertThat(result).isEqualTo(TextReference.EMPTY) + } + + @Test + fun `GIVEN zero total WHEN toForYouPercentText THEN returns EMPTY`() { + // Arrange + val amount = BigDecimal("10") + + // Act + val result = amount.toForYouPercentText(BigDecimal.ZERO) + + // Assert + assertThat(result).isEqualTo(TextReference.EMPTY) + } + + @Test + fun `GIVEN zero amount WHEN toForYouPercentText THEN returns EMPTY`() { + // Arrange + val amount = BigDecimal.ZERO + + // Act + val result = amount.toForYouPercentText(BigDecimal("100")) + + // Assert + assertThat(result).isEqualTo(TextReference.EMPTY) + } + + @Test + fun `GIVEN non-zero amount and total WHEN toForYouPercentText THEN returns rounded percent share`() { + // Arrange + val amount = BigDecimal("25.00") + val total = BigDecimal("100") + + // Act + val result = amount.toForYouPercentText(total) + + // Assert — 25.00 / 100 = 0.25 -> 25.00% + assertThat(result).isEqualTo(stringReference("25.00%")) + } + + @Test + fun `GIVEN a share requiring rounding WHEN toForYouPercentText THEN applies HALF_UP rounding`() { + // Arrange — 1.0000 / 3 = 0.3333... -> rounds to 33.33% + val amount = BigDecimal("1.0000") + val total = BigDecimal("3") + + // Act + val result = amount.toForYouPercentText(total) + + // Assert + assertThat(result).isEqualTo(stringReference("33.33%")) + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt new file mode 100644 index 0000000000..ff86191938 --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt @@ -0,0 +1,285 @@ +package com.tangem.features.foryou.impl.model.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +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.features.foryou.impl.R +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class ForYouTokenListConverterTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + + @Nested + inner class Convert { + + @Test + fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) + val converter = createConverter(totalFiatBalance = BigDecimal("100")) + + // Act + val result = converter.convert(listOf(status)) + + // Assert + val row = result.single().tokenRowUM as TangemTokenRowUM.Content + val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network)) + } + + @Test + fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() { + // Arrange + val currency = createToken( + rawCurrencyId = "usdc", + symbol = "USDC", + networkId = "ethereum", + standardTypeName = "ERC20", + ) + val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) + val converter = createConverter(totalFiatBalance = BigDecimal("100")) + + // Act + val result = converter.convert(listOf(status)) + + // Assert + val row = result.single().tokenRowUM as TangemTokenRowUM.Content + val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(stringReference("ERC20")) + } + + @Test + fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count`() { + // Arrange — same asset (shared rawCurrencyId) on two different networks + val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") + val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") + val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100"))) + val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200"))) + val converter = createConverter( + totalFiatBalance = BigDecimal("300"), + ) + + // Act + val result = converter.convert(listOf(statusEth, statusSol)) + + // Assert + val item = result.single() + val row = item.tokenRowUM as TangemTokenRowUM.Content + val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(stringReference("2 networks")) + assertThat(item.tokenList).hasSize(2) + } + + @Test + fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() { + // Arrange + val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") + val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") + val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100"))) + val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500"))) + val converter = createConverter( + totalFiatBalance = BigDecimal("600"), + ) + + // Act + val result = converter.convert(listOf(statusEth, statusSol)) + + // Assert — Solana holding (500) ranks above Ethereum holding (100) + val childIds = result.single().tokenList.map { it.id } + assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder() + } + + @Test + fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val status = createStatus(currency, CryptoCurrencyStatus.Loading) + val converter = createConverter(totalFiatBalance = BigDecimal.ZERO) + + // Act + val result = converter.convert(listOf(status)) + + // Assert + assertThat(result.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java) + } + + @Test + fun `GIVEN otherAssetCount is zero WHEN convert THEN no Other row is appended`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) + val converter = createConverter( + totalFiatBalance = BigDecimal("100"), + otherAssetCount = 0, + ) + + // Act + val result = converter.convert(listOf(status)) + + // Assert + assertThat(result).hasSize(1) + } + + @Test + fun `GIVEN otherAssetCount is one WHEN convert THEN Other row subtitle is singular`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) + val converter = createConverter( + totalFiatBalance = BigDecimal("100"), + otherAssetCount = 1, + otherFiatBalance = BigDecimal("50"), + ) + + // Act + val result = converter.convert(listOf(status)) + + // Assert + val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content + assertThat(otherRow.id).isEqualTo("for_you_other_assets") + val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(stringReference("1 asset")) + } + + @Test + fun `GIVEN otherAssetCount is more than one WHEN convert THEN Other row subtitle is plural`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) + val converter = createConverter( + totalFiatBalance = BigDecimal("100"), + otherAssetCount = 3, + otherFiatBalance = BigDecimal("50"), + ) + + // Act + val result = converter.convert(listOf(status)) + + // Assert + val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content + val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(stringReference("3 assets")) + } + + @Test + fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) + val converter = createConverter( + totalFiatBalance = BigDecimal("100"), + expandedAssetIds = setOf("bitcoin"), + ) + + // Act + val result = converter.convert(listOf(status)) + + // Assert + assertThat(result.single().isExpanded).isTrue() + } + + @Test + fun `GIVEN asset id not in expandedAssetIds WHEN convert THEN item isExpanded is false`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) + val converter = createConverter( + totalFiatBalance = BigDecimal("100"), + expandedAssetIds = emptySet(), + ) + + // Act + val result = converter.convert(listOf(status)) + + // Assert + assertThat(result.single().isExpanded).isFalse() + } + } + + private fun createConverter( + totalFiatBalance: BigDecimal, + expandedAssetIds: Set = emptySet(), + otherAssetCount: Int = 0, + otherFiatBalance: BigDecimal = BigDecimal.ZERO, + ): ForYouTokenListConverter = ForYouTokenListConverter( + appCurrency = appCurrency, + totalFiatBalance = totalFiatBalance, + expandedAssetIds = expandedAssetIds, + expandClick = {}, + otherAssetCount = otherAssetCount, + otherFiatBalance = otherFiatBalance, + ) + + private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( + currency = currency, + value = value, + ) + + private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { + every { this@mockk.amount } returns amount + every { this@mockk.fiatAmount } returns fiatAmount + every { isError } returns false + } + + private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin { + val network = createNetwork(networkId = networkId, standardTypeName = "MAIN") + val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.symbol } returns symbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns 8 + every { isCustom } returns false + every { iconUrl } returns null + } + } + + private fun createToken( + rawCurrencyId: String, + symbol: String, + networkId: String, + standardTypeName: String = "ERC20", + ): CryptoCurrency.Token { + val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName) + val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.symbol } returns symbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns 6 + every { isCustom } returns false + every { iconUrl } returns null + every { contractAddress } returns "0xCONTRACT" + } + } + + private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk { + every { value } returns idValue + every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) + } + + private fun createNetwork(networkId: String, standardTypeName: String): Network { + val standardType: Network.StandardType = mockk { + every { name } returns standardTypeName + } + return mockk { + every { id } returns mockk { + every { rawId } returns Network.RawID(networkId) + } + every { name } returns networkId + every { isTestnet } returns false + every { this@mockk.standardType } returns standardType + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt new file mode 100644 index 0000000000..c9a0a73940 --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt @@ -0,0 +1,131 @@ +package com.tangem.features.foryou.impl.model.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +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 io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class ForYouTokenRowConverterTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + + @Nested + inner class ConvertNetworkGroup { + + @Test + fun `GIVEN all statuses Loading WHEN convertNetworkGroup THEN row is Loading with representative id`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH") + val statuses = listOf(createStatus(currency, CryptoCurrencyStatus.Loading)) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) + + // Assert + assertThat(result).isEqualTo(TangemTokenRowUM.Loading(id = "coin-eth")) + } + + @Test + fun `GIVEN single loaded status WHEN convertNetworkGroup THEN row is Content with its amounts`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert + assertThat(result.id).isEqualTo("coin-eth") + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.text).isEqualTo(BigDecimal("400").toForYouFiatText(appCurrency)) + assertThat(bottomEnd.text).isEqualTo(BigDecimal("400").toForYouPercentText(BigDecimal("1000"))) + } + + @Test + fun `GIVEN several statuses of the same asset on one network WHEN convertNetworkGroup THEN amounts are summed`() { + // Arrange — same asset held in two accounts on the same network aggregates into one row + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("200"))), + createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.text).isEqualTo(BigDecimal("600").toForYouFiatText(appCurrency)) + } + + @Test + fun `GIVEN mixed Loading and Loaded statuses WHEN convertNetworkGroup THEN row is Content`() { + // Arrange — not *all* statuses are Loading, so it should not collapse to a Loading row + val currency = createCurrency(id = "coin-eth", symbol = "ETH") + val statuses = listOf( + createStatus(currency, CryptoCurrencyStatus.Loading), + createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) + + // Assert + assertThat(result).isInstanceOf(TangemTokenRowUM.Content::class.java) + } + } + + private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter( + appCurrency = appCurrency, + totalFiatBalance = totalFiatBalance, + ) + + private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( + currency = currency, + value = value, + ) + + private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { + every { this@mockk.amount } returns amount + every { this@mockk.fiatAmount } returns fiatAmount + every { isError } returns false + } + + private fun createCurrency( + id: String, + symbol: String, + networkName: String = "Network", + ): CryptoCurrency { + val network: Network = mockk { + every { name } returns networkName + every { isTestnet } returns false + every { this@mockk.id } returns mockk { every { rawId } returns Network.RawID(id) } + } + val currencyId: CryptoCurrency.ID = mockk { + every { value } returns id + every { rawCurrencyId } returns null + } + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.symbol } returns symbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns 8 + every { isCustom } returns false + every { iconUrl } returns null + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt new file mode 100644 index 0000000000..76ddeda041 --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt @@ -0,0 +1,243 @@ +package com.tangem.features.foryou.impl.model.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.stringReference +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.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM +import com.tangem.features.foryou.impl.entity.ForYouUM +import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class SetPortfolioReviewTransformerTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + private val walletListUM = WalletListUM(items = persistentListOf()) + + @Nested + inner class Transform { + + @Test + fun `GIVEN currency with zero fiat balance WHEN transform THEN it is dropped from asset count`() { + // Arrange + val zeroBalance = createCurrency(rawCurrencyId = "btc", symbol = "BTC") + val nonZeroBalance = createCurrency(rawCurrencyId = "eth", symbol = "ETH") + val currencies = listOf( + createStatus(zeroBalance, loadedValue(BigDecimal.ZERO)), + createStatus(nonZeroBalance, loadedValue(BigDecimal("100"))), + ) + val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("100")) + + // Act + val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + + // Assert + assertThat(result.assetCount).isEqualTo(stringReference("1 assets")) + } + + @Test + fun `GIVEN assets across networks WHEN transform THEN they are aggregated and ranked by summed fiat`() { + // Arrange — same asset (rawCurrencyId "usdc") on two networks aggregates into one asset + val onEth = createCurrency(rawCurrencyId = "usdc", symbol = "USDC") + val onSol = createCurrency(rawCurrencyId = "usdc", symbol = "USDC") + val other = createCurrency(rawCurrencyId = "btc", symbol = "BTC") + val currencies = listOf( + createStatus(onEth, loadedValue(BigDecimal("50"))), + createStatus(onSol, loadedValue(BigDecimal("60"))), + createStatus(other, loadedValue(BigDecimal("10"))), + ) + val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("120")) + + // Act + val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + + // Assert — 2 ranked assets: usdc (110 total) and btc (10) + assertThat(result.assetCount).isEqualTo(stringReference("2 assets")) + } + + @Test + fun `GIVEN more than TOP_HOLDINGS_COUNT assets WHEN transform THEN excess assets collapse into Other`() { + // Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other" + val currencies = (1..5).map { index -> + createStatus( + createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"), + loadedValue(BigDecimal(100 - index)), + ) + } + val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("470")) + + // Act + val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + + // Assert — tokenList has 4 top asset rows + 1 "Other" row = 5 items + assertThat(result.tokenList).hasSize(5) + assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets") + } + + @Test + fun `GIVEN exactly TOP_HOLDINGS_COUNT assets WHEN transform THEN no Other row is appended`() { + // Arrange + val currencies = (1..4).map { index -> + createStatus( + createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"), + loadedValue(BigDecimal(100 - index)), + ) + } + val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("394")) + + // Act + val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + + // Assert + assertThat(result.tokenList).hasSize(4) + } + + @Test + fun `GIVEN total and top balance non-zero WHEN transform THEN topHoldingPercent is computed`() { + // Arrange — a single asset means top balance == total balance == 100% + val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") + val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))) + val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("100")) + + // Act + val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + + // Assert + assertThat(result.topHoldingPercent).isEqualTo(stringReference("Top holding 100.00%")) + } + + @Test + fun `GIVEN zero total fiat balance WHEN transform THEN topHoldingPercent is DASH_SIGN`() { + // Arrange + val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") + val currencies = listOf(createStatus(currency, loadedValue(BigDecimal.ZERO))) + val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal.ZERO) + + // Act + val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + + // Assert + assertThat(result.topHoldingPercent).isEqualTo(stringReference("Top holding —")) + } + + @Test + fun `GIVEN prev state is Loading WHEN transform THEN period picker is freshly created with Day selected`() { + // Arrange + val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") + val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10")))) + val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("10")) + + // Act + val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + + // Assert + assertThat(result.periodPickerUM.items.map { it.title }).containsExactly( + stringReference("Day"), + stringReference("Week"), + stringReference("Month"), + ).inOrder() + assertThat(result.periodPickerUM.initialSelectedItem?.title).isEqualTo(stringReference("Day")) + } + + @Test + fun `GIVEN prev state is Content WHEN transform THEN period picker selection is preserved`() { + // Arrange + val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") + val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10")))) + val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("10")) + val prevContentState = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + val weekItem = prevContentState.periodPickerUM.items[1] + val prevWithWeekSelected = prevContentState.copy( + periodPickerUM = prevContentState.periodPickerUM.copy(initialSelectedItem = weekItem), + ) + val prevState = ForYouUM(walletListUM = walletListUM, portfolioReviewUM = prevWithWeekSelected) + + // Act + val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content + + // Assert + assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) + } + + @Test + fun `GIVEN new state WHEN transform THEN walletListUM is applied from constructor`() { + // Arrange + val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") + val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10")))) + val newWalletListUM = WalletListUM(items = persistentListOf()) + val transformer = SetPortfolioReviewTransformer( + walletListUM = newWalletListUM, + currencies = currencies, + totalFiatBalance = BigDecimal("10"), + appCurrency = appCurrency, + expandedAssetIds = emptySet(), + expandClick = {}, + onPeriodClick = {}, + ) + + // Act + val result = transformer.transform(loadingState()) + + // Assert + assertThat(result.walletListUM).isSameInstanceAs(newWalletListUM) + } + } + + private fun createTransformer( + currencies: List, + totalFiatBalance: BigDecimal, + expandedAssetIds: Set = emptySet(), + ) = SetPortfolioReviewTransformer( + walletListUM = walletListUM, + currencies = currencies, + totalFiatBalance = totalFiatBalance, + appCurrency = appCurrency, + expandedAssetIds = expandedAssetIds, + expandClick = {}, + onPeriodClick = {}, + ) + + private fun loadingState(): ForYouUM = ForYouUM( + walletListUM = walletListUM, + portfolioReviewUM = PortfolioReviewUM.Loading(tokenList = persistentListOf()), + ) + + private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( + currency = currency, + value = value, + ) + + private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { + every { amount } returns BigDecimal.ONE + every { this@mockk.fiatAmount } returns fiatAmount + every { isError } returns false + } + + private fun createCurrency(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin { + val network: Network = mockk { + every { name } returns "Network" + every { isTestnet } returns false + every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) } + } + val currencyId: CryptoCurrency.ID = mockk { + every { value } returns "coin-$rawCurrencyId" + every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) + } + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.symbol } returns symbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns 8 + every { isCustom } returns false + every { iconUrl } returns null + } + } +} \ No newline at end of file