From a252e3351591ee3fb3678074ddb1d10bba0868de Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Sep 2025 13:55:41 +0700 Subject: [PATCH] Updated on 2026-08-14 --- ...ccountCryptoPortfolioItemStateConverter.kt | 100 ++++++++++++ .../account/AccountIconItemStateConverter.kt | 24 +++ .../modal/TangemModalBottomSheet.kt | 94 +++++++---- .../components/currency/icon/ContentIcon.kt | 3 +- .../currency/icon/CurrencyIconState.kt | 4 +- .../core/ui/components/token/TokenItem.kt | 2 +- features/account/api/build.gradle.kts | 5 + .../account/AccountSelectorComponent.kt | 24 +++ .../account/AccountsBalanceFetcher.kt | 39 +++++ features/account/impl/build.gradle.kts | 9 ++ .../fetcher/DefaultAccountsBalanceFetcher.kt | 120 ++++++++++++++ .../account/selector/AccountSelectorModel.kt | 87 +++++++++++ .../DefaultAccountSelectorComponent.kt | 69 +++++++++ .../DefaultAccountSelectorController.kt | 18 +++ .../selector/entity/AccountSelectorUM.kt | 28 ++++ .../account/selector/ui/AccountSelectorBS.kt | 61 ++++++++ .../selector/ui/AccountSelectorContent.kt | 146 ++++++++++++++++++ 17 files changed, 796 insertions(+), 37 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt create mode 100644 common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt create mode 100644 features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt create mode 100644 features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultAccountsBalanceFetcher.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt create mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt new file mode 100644 index 0000000000..e2b2b60cd9 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt @@ -0,0 +1,100 @@ +package com.tangem.common.ui.account + +import com.tangem.common.ui.R +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.wrappedList +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.StatusSource +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.utils.converter.Converter + +class AccountCryptoPortfolioItemStateConverter( + private val appCurrency: AppCurrency, + private val account: Account.CryptoPortfolio, + private val onItemClick: ((Account.CryptoPortfolio) -> Unit)? = null, + private val onItemLongClick: ((Account.CryptoPortfolio) -> Unit)? = null, +) : Converter { + + override fun convert(value: TotalFiatBalance): TokenItemState { + return when (value) { + is TotalFiatBalance.Loaded -> account.mapToContentState(value) + TotalFiatBalance.Failed -> account.mapToUnreachableState() + TotalFiatBalance.Loading -> account.mapToLoadingState() + } + } + + private fun Account.CryptoPortfolio.mapToContentState( + fiatBalance: TotalFiatBalance.Loaded, + ): TokenItemState.Content { + return TokenItemState.Content( + id = account.accountId.value, + iconState = AccountIconItemStateConverter.convert(this), + titleState = TokenItemState.TitleState.Content( + text = accountName.toUM().value, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + fiatAmountState = FiatAmountState.Content( + text = fiatBalance.amount + .format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, + isFlickering = fiatBalance.source == StatusSource.CACHE, + ), + subtitle2State = null, + onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, + onItemLongClick = onItemLongClick?.let { onItemLongClick -> { onItemLongClick(account) } }, + ) + } + + private fun Account.CryptoPortfolio.mapToLoadingState(): TokenItemState.Loading { + return TokenItemState.Loading( + id = account.accountId.value, + iconState = AccountIconItemStateConverter.convert(account), + titleState = TokenItemState.TitleState.Content( + text = accountName.toUM().value, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + ) + } + + private fun Account.CryptoPortfolio.mapToUnreachableState(): TokenItemState.Unreachable { + return TokenItemState.Unreachable( + id = account.accountId.value, + iconState = AccountIconItemStateConverter.convert(account), + titleState = TokenItemState.TitleState.Content( + text = accountName.toUM().value, + ), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + onItemClick = onItemClick?.let { onItemClick -> + { onItemClick(account) } + }, + onItemLongClick = onItemLongClick?.let { onItemLongClick -> + { onItemLongClick(account) } + }, + ) + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt new file mode 100644 index 0000000000..f89a37e3db --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconItemStateConverter.kt @@ -0,0 +1,24 @@ +package com.tangem.common.ui.account + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.utils.converter.Converter + +object AccountIconItemStateConverter : Converter { + + override fun convert(value: Account): CurrencyIconState.CryptoPortfolio = when (value) { + is Account.CryptoPortfolio -> when { + value.icon.value == CryptoPortfolioIcon.Icon.Letter -> CurrencyIconState.CryptoPortfolio.Letter( + char = value.accountName.toUM().value, + color = value.icon.color.getUiColor(), + isGrayscale = false, + ) + else -> CurrencyIconState.CryptoPortfolio.Icon( + resId = value.icon.value.getResId(), + color = value.icon.color.getUiColor(), + isGrayscale = false, + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index 0c76ab802c..196a5f2012 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -50,6 +50,7 @@ inline fun TangemModalBottomSheet( containerColor: Color = TangemTheme.colors.background.primary, skipPartiallyExpanded: Boolean = true, dismissOnClickOutside: Boolean = true, + scrollableContent: Boolean = true, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable ColumnScope.(T) -> Unit, @@ -62,6 +63,7 @@ inline fun TangemModalBottomSheet( containerColor = containerColor, title = title, content = content, + scrollableContent = scrollableContent, skipPartiallyExpanded = skipPartiallyExpanded, ) } else { @@ -71,6 +73,7 @@ inline fun TangemModalBottomSheet( title = title, content = content, dismissOnClickOutside = dismissOnClickOutside, + scrollableContent = scrollableContent, onBack = onBack, skipPartiallyExpanded = skipPartiallyExpanded, ) @@ -84,6 +87,7 @@ inline fun DefaultModalBottomSheet( containerColor: Color, skipPartiallyExpanded: Boolean = true, dismissOnClickOutside: Boolean = true, + scrollableContent: Boolean = true, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), @@ -104,10 +108,16 @@ inline fun DefaultModalBottomSheet( BasicModalBottomSheet( config = config, sheetState = sheetState, - containerColor = containerColor, - title = title, onBack = onBack, - content = content, + bsContent = { + BsContent( + config = config, + containerColor = containerColor, + scrollableContent = scrollableContent, + title = title, + content = content, + ) + }, ) } @@ -126,6 +136,7 @@ inline fun PreviewModalBottomSheet( config: TangemBottomSheetConfig, containerColor: Color, skipPartiallyExpanded: Boolean = true, + scrollableContent: Boolean = true, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { @@ -137,49 +148,64 @@ inline fun PreviewModalBottomSheet( density = LocalDensity.current, ), onBack = null, - containerColor = containerColor, - title = title, - content = content, + bsContent = { + BsContent( + config = config, + containerColor = containerColor, + scrollableContent = scrollableContent, + title = title, + content = content, + ) + }, ) } +@Composable +inline fun BsContent( + config: TangemBottomSheetConfig, + containerColor: Color, + scrollableContent: Boolean = true, + crossinline title: @Composable (BoxScope.(T) -> Unit), + crossinline content: @Composable (ColumnScope.(T) -> Unit), +) { + val model = config.content as? T ?: return + + val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT + + Column( + modifier = Modifier + .systemBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp) + .clip(TangemTheme.shapes.roundedCornersLarge) + .background(containerColor) + .heightIn(max = maxHeight.dp) + .fillMaxWidth(), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + title(model) + } + if (scrollableContent) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + content(model) + } + } else { + content(model) + } + } +} + @Suppress("LongParameterList") @OptIn(ExperimentalMaterial3Api::class) @Composable inline fun BasicModalBottomSheet( config: TangemBottomSheetConfig, sheetState: SheetState, - containerColor: Color, noinline onBack: (() -> Unit)? = null, - crossinline title: @Composable (BoxScope.(T) -> Unit), - crossinline content: @Composable (ColumnScope.(T) -> Unit), + noinline bsContent: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, ) { - val model = config.content as? T ?: return - - val bsContent: @Composable ColumnScope.() -> Unit = { - val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT - - Column( - modifier = Modifier - .systemBarsPadding() - .padding(horizontal = 8.dp, vertical = 8.dp) - .clip(TangemTheme.shapes.roundedCornersLarge) - .background(containerColor) - .heightIn(max = maxHeight.dp) - .fillMaxWidth(), - ) { - Box(modifier = Modifier.fillMaxWidth()) { - title(model) - } - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - content(model) - } - } - } - if (onBack != null) { ModalBottomSheetWithBackHandling( modifier = modifier, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt index 4213831216..02e42068d6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.components.account.AccountCharIcon import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.account.AccountResIcon import com.tangem.core.ui.components.currency.DefaultCurrencyIcon +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @Composable @@ -69,7 +70,7 @@ internal fun ContentIcon( ) is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon( modifier = modifier, - char = icon.char, + char = icon.char.resolveReference().first(), color = icon.color, size = AccountIconSize.Default, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt index 2075631d63..65107776ea 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt @@ -4,6 +4,7 @@ import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference /** * Represents the various states an icon can be in. @@ -86,6 +87,7 @@ sealed class CurrencyIconState { override val topBadgeIconResId: Int? = null } + @Immutable sealed class CryptoPortfolio : CurrencyIconState() { override val showCustomBadge: Boolean = false override val topBadgeIconResId: Int? = null @@ -98,7 +100,7 @@ sealed class CurrencyIconState { ) : CryptoPortfolio() data class Letter( - val char: Char, + val char: TextReference, override val color: Color, override val isGrayscale: Boolean, ) : CryptoPortfolio() diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt index 3c74bb41fd..9a4585eb59 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/TokenItem.kt @@ -661,7 +661,7 @@ object AccountItemPreviewData { ) val accountLetterIcon: CurrencyIconState.CryptoPortfolio.Letter get() = CurrencyIconState.CryptoPortfolio.Letter( - char = 'A', + char = stringReference("A"), color = Color.Blue, isGrayscale = false, ) diff --git a/features/account/api/build.gradle.kts b/features/account/api/build.gradle.kts index e3e35ac4cd..5e9cc443b6 100644 --- a/features/account/api/build.gradle.kts +++ b/features/account/api/build.gradle.kts @@ -13,7 +13,12 @@ dependencies { /* Project - Core */ implementation(projects.core.decompose) implementation(projects.core.ui) + implementation(projects.common.ui) /* Project - Domain */ implementation(projects.domain.models) + implementation(projects.domain.core) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) } \ No newline at end of file diff --git a/features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt b/features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt new file mode 100644 index 0000000000..4e751499ed --- /dev/null +++ b/features/account/api/src/main/java/com/tangem/features/account/AccountSelectorComponent.kt @@ -0,0 +1,24 @@ +package com.tangem.features.account + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.account.Account +import kotlinx.coroutines.flow.StateFlow + +interface AccountSelectorComponent : ComposableBottomSheetComponent { + + data class Params( + val onDismiss: () -> Unit, + val accountsBalanceFetcher: AccountsBalanceFetcher, + val controller: AccountSelectorController, + ) + + interface Factory { + fun create(appComponentContext: AppComponentContext, params: Params): AccountSelectorComponent + } +} + +interface AccountSelectorController { + val selectedAccount: StateFlow + fun selectAccount(account: Account?) +} \ No newline at end of file diff --git a/features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt b/features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt new file mode 100644 index 0000000000..77283e9678 --- /dev/null +++ b/features/account/api/src/main/java/com/tangem/features/account/AccountsBalanceFetcher.kt @@ -0,0 +1,39 @@ +package com.tangem.features.account + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListError +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface AccountsBalanceFetcher { + + val data: Flow + + val mode: StateFlow + fun updateMode(mode: Mode) + + data class Data( + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val balances: Map>, + ) + + data class AccountBalance( + val balance: Lce, + ) + + sealed interface Mode { + data class All(val onlyMultiCurrency: Boolean) : Mode + data class Wallet(val walletId: UserWalletId) : Mode + } + + interface Factory { + fun create(mode: Mode, scope: CoroutineScope): AccountsBalanceFetcher + } +} \ No newline at end of file diff --git a/features/account/impl/build.gradle.kts b/features/account/impl/build.gradle.kts index dc77a38d28..924e0c0cdc 100644 --- a/features/account/impl/build.gradle.kts +++ b/features/account/impl/build.gradle.kts @@ -29,6 +29,15 @@ dependencies { /** Domain */ implementation(projects.domain.models) implementation(projects.domain.account) + implementation(projects.domain.core) + implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) /** Common */ implementation(projects.common.ui) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultAccountsBalanceFetcher.kt b/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultAccountsBalanceFetcher.kt new file mode 100644 index 0000000000..2d9db0f8a7 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultAccountsBalanceFetcher.kt @@ -0,0 +1,120 @@ +package com.tangem.features.account.fetcher + +import arrow.core.getOrElse +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.account.AccountsBalanceFetcher +import com.tangem.features.account.AccountsBalanceFetcher.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +internal class DefaultAccountsBalanceFetcher @AssistedInject constructor( + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getWallets: GetWalletsUseCase, + private val dispatchers: CoroutineDispatcherProvider, + @Assisted mode: Mode, + @Assisted private val scope: CoroutineScope, +) : AccountsBalanceFetcher { + + private val _mode = MutableStateFlow(mode) + private val _data = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val data: Flow + get() = _data.distinctUntilChanged() + override val mode: StateFlow + get() = _mode + + init { + _mode.flatMapLatest(::combineUseCases) + .flowOn(dispatchers.default) + .onEach { _data.emit(it) } + .launchIn(scope) + } + + override fun updateMode(mode: Mode) { + _mode.value = mode + } + + private fun combineUseCases(mode: Mode) = combine( + flow = getWallets() + .map { it.filterWallets(mode) } + .distinctUntilChanged() + .flatMapLatest { wallets -> balancesForWallets(wallets) }, + flow2 = appCurrencyFlow(), + flow3 = balanceHidingFlow(), + ) { balances, appCurrency, isBalanceHiding -> + Data( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHiding, + balances = balances, + ) + } + + private fun List.filterWallets(mode: Mode): List = this.filter { wallet -> + when (mode) { + is Mode.All -> if (mode.onlyMultiCurrency) wallet.isMultiCurrency else true + is Mode.Wallet -> wallet.walletId == mode.walletId + } + } + + private fun balancesForWallets(wallets: List): Flow>> = + wallets.asFlow() + .map { walletAccountsBalancesFlow(it) } + .mapLatest { accountsBalances -> combine(accountsBalances) { pairs -> pairs.toMap() } } + .flattenConcat() + + private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow>> = + walletAccounts(wallet) + .distinctUntilChanged() + .map { list -> list.map(::accountBalanceFlow) } + .mapLatest { balanceFlows -> combine(balanceFlows) { balances -> balances.toMap() } } + .flattenConcat() + .map { accountBalance -> wallet to accountBalance } + + private fun walletAccounts(wallet: UserWallet): Flow> = flow { + // todo account load accounts + val accounts: List = Account.CryptoPortfolio + .createMainAccount(wallet.walletId) + .let(::listOf) + emit(accounts) + } + + private fun accountBalanceFlow(account: Account): Flow> = flow { + // todo account load balance + val balance = AccountBalance(balance = Lce.Content(TotalFiatBalance.Loading)) + emit(account to balance) + } + + private fun appCurrencyFlow(): Flow { + return getSelectedAppCurrencyUseCase() + .map { it.getOrElse { AppCurrency.Default } } + .distinctUntilChanged() + } + + private fun balanceHidingFlow(): Flow { + return getBalanceHidingSettingsUseCase() + .map { it.isBalanceHidden } + .distinctUntilChanged() + } + + @AssistedFactory + interface Factory : AccountsBalanceFetcher.Factory { + override fun create(mode: Mode, scope: CoroutineScope): DefaultAccountsBalanceFetcher + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt new file mode 100644 index 0000000000..5012e93d14 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/AccountSelectorModel.kt @@ -0,0 +1,87 @@ +package com.tangem.features.account.selector + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.utils.getOrElse +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.features.account.AccountSelectorComponent +import com.tangem.features.account.AccountsBalanceFetcher +import com.tangem.features.account.selector.entity.AccountSelectorItemUM +import com.tangem.features.account.selector.entity.AccountSelectorUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@ModelScoped +internal class AccountSelectorModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + private val balanceFetcher get() = params.accountsBalanceFetcher + private val selectorController get() = params.controller + + internal val state: StateFlow + field = MutableStateFlow(emptyState()) + + init { + balanceFetcher.data + .map { data -> + AccountSelectorUM( + isSingleWallet = balanceFetcher.mode.value is AccountsBalanceFetcher.Mode.Wallet, + items = buildUiList(data).toImmutableList(), + ) + } + .launchIn(modelScope) + } + + private fun buildUiList(data: AccountsBalanceFetcher.Data) = buildList { + fun Account.accountItemState(balance: Lce): TokenItemState { + val totalFiatBalance = balance.getOrElse( + ifError = { TotalFiatBalance.Failed }, + ifLoading = { TotalFiatBalance.Loading }, + ) + return when (this) { + is Account.CryptoPortfolio -> AccountCryptoPortfolioItemStateConverter( + appCurrency = data.appCurrency, + account = this, + onItemClick = { selectorController.selectAccount(it) }, + ).convert(totalFiatBalance) + } + } + + data.balances.forEach { wallet, accounts -> + if (wallet.isLocked) return@forEach + AccountSelectorItemUM.Wallet( + id = wallet.walletId.stringValue, + name = stringReference(wallet.name), + ).let(::add) + + accounts.forEach { account, balance -> + AccountSelectorItemUM.Account( + account = account.accountItemState(balance.balance), + isBalanceHidden = data.isBalanceHidden, + ).let(::add) + } + } + } + + private fun emptyState() = AccountSelectorUM( + items = persistentListOf(), + balanceFetcher.mode.value is AccountsBalanceFetcher.Mode.Wallet, + ) +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt new file mode 100644 index 0000000000..bc53b7bfb3 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorComponent.kt @@ -0,0 +1,69 @@ +package com.tangem.features.account.selector + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.account.AccountSelectorComponent +import com.tangem.features.account.impl.R +import com.tangem.features.account.selector.ui.AccountSelectorContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAccountSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: AccountSelectorComponent.Params, +) : AppComponentContext by appComponentContext, AccountSelectorComponent { + + private val model: AccountSelectorModel = getOrCreateModel(params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.state.collectAsStateWithLifecycle() + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = ::dismiss, + containerColor = TangemTheme.colors.background.primary, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.common_choose_wallet), + startIconRes = R.drawable.ic_back_24, + onStartClick = ::dismiss, + ) + }, + content = { + AccountSelectorContent( + state = state, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + }, + ) + } + + @AssistedFactory + interface Factory : AccountSelectorComponent.Factory { + override fun create( + appComponentContext: AppComponentContext, + params: AccountSelectorComponent.Params, + ): DefaultAccountSelectorComponent + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt new file mode 100644 index 0000000000..9be5ae3197 --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultAccountSelectorController.kt @@ -0,0 +1,18 @@ +package com.tangem.features.account.selector + +import com.tangem.domain.models.account.Account +import com.tangem.features.account.AccountSelectorController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +internal class DefaultAccountSelectorController @Inject constructor() : AccountSelectorController { + + private val _selectedAccount: MutableStateFlow = MutableStateFlow(null) + override val selectedAccount: StateFlow get() = _selectedAccount + + override fun selectAccount(account: Account?) { + _selectedAccount.update { account } + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt new file mode 100644 index 0000000000..d8d05f0fea --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/AccountSelectorUM.kt @@ -0,0 +1,28 @@ +package com.tangem.features.account.selector.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +data class AccountSelectorUM( + val items: ImmutableList, + val isSingleWallet: Boolean, +) + +@Immutable +sealed interface AccountSelectorItemUM { + val id: String + + data class Wallet( + override val id: String, + val name: TextReference, + ) : AccountSelectorItemUM + + data class Account( + val account: TokenItemState, + val isBalanceHidden: Boolean, + ) : AccountSelectorItemUM { + override val id: String = account.id + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt new file mode 100644 index 0000000000..ec1cad3c9e --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorBS.kt @@ -0,0 +1,61 @@ +package com.tangem.features.account.selector.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.account.impl.R +import com.tangem.features.account.selector.entity.AccountSelectorUM + +@Composable +internal fun AccountSelectorBS(state: AccountSelectorUM, onDismiss: () -> Unit, modifier: Modifier = Modifier) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = onDismiss, + scrollableContent = false, + containerColor = TangemTheme.colors.background.secondary, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(R.string.common_choose_account), + startIconRes = R.drawable.ic_back_24, + onStartClick = onDismiss, + ) + }, + content = { + AccountSelectorContent( + state = state, + contentPadding = PaddingValues(bottom = 16.dp), + modifier = modifier.padding(horizontal = 16.dp), + ) + }, + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(AccountSelectorPreviewStateProvider::class) params: AccountSelectorUM) { + TangemThemePreview { + AccountSelectorBS( + state = params, + onDismiss = {}, + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) + } +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt new file mode 100644 index 0000000000..a8f54b000d --- /dev/null +++ b/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/AccountSelectorContent.kt @@ -0,0 +1,146 @@ +package com.tangem.features.account.selector.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.token.AccountItemPreviewData +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.account.selector.entity.AccountSelectorItemUM +import com.tangem.features.account.selector.entity.AccountSelectorUM +import com.tangem.features.account.selector.ui.AccountSelectorPreviewData.firstList +import kotlinx.collections.immutable.toImmutableList +import java.util.UUID + +@Composable +internal fun AccountSelectorContent( + state: AccountSelectorUM, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + ) { + val items = state.items + itemsIndexed( + items = items, + key = { _, item -> item.id }, + ) { index, item -> + val previewItem = items.getOrNull(index.dec()) + val offsetModifier = when { + previewItem == null -> Modifier + state.isSingleWallet -> Modifier.padding( + top = TangemTheme.dimens.spacing16, + ) + item is AccountSelectorItemUM.Wallet -> Modifier.padding( + top = TangemTheme.dimens.spacing16, + ) + else -> Modifier.padding( + top = TangemTheme.dimens.spacing8, + ) + } + + when (item) { + is AccountSelectorItemUM.Account -> TokenItem( + state = item.account, + isBalanceHidden = item.isBalanceHidden, + modifier = offsetModifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.primary), + ) + is AccountSelectorItemUM.Wallet -> WalletNameRow( + model = item, + modifier = offsetModifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + } + } +} + +@Composable +private fun WalletNameRow(model: AccountSelectorItemUM.Wallet, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = model.name.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AccountSelectorContentPreview( + @PreviewParameter(AccountSelectorPreviewStateProvider::class) params: AccountSelectorUM, +) { + TangemThemePreview { + AccountSelectorContent( + state = params, + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) + } +} + +internal object AccountSelectorPreviewData { + val firstList + get() = buildList { + AccountSelectorItemUM.Wallet( + id = UUID.randomUUID().toString(), + name = stringReference("Tangem 2.0"), + ).let(::add) + AccountItemPreviewData.accountItem + .let { AccountSelectorItemUM.Account(it, false) } + .let(::add) + AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon) + .let { AccountSelectorItemUM.Account(it, false) } + .let(::add) + AccountSelectorItemUM.Wallet( + id = UUID.randomUUID().toString(), + name = stringReference("Tangem White"), + ).let(::add) + AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon) + .let { AccountSelectorItemUM.Account(it, false) } + .let(::add) + } +} + +internal class AccountSelectorPreviewStateProvider : CollectionPreviewParameterProvider( + buildList { + val secondList = listOf( + AccountItemPreviewData.accountItem, + AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon), + ).map { AccountSelectorItemUM.Account(it, false) } + + val first = AccountSelectorUM( + items = firstList.toImmutableList(), + isSingleWallet = false, + ) + val second = AccountSelectorUM( + items = secondList.toImmutableList(), + isSingleWallet = true, + ) + add(first) + add(second) + }, +) \ No newline at end of file