Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-15 14:25:11 +03:00
commit 5ee5b4f61b
563 changed files with 13917 additions and 4692 deletions

View file

@ -2,15 +2,15 @@ package com.tangem.features.account
import com.tangem.domain.account.models.AccountStatusList
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.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
/**
* How to use see [PortfolioSelectorComponent]
*/
interface PortfolioFetcher {
val data: Flow<Data>
@ -25,10 +25,11 @@ interface PortfolioFetcher {
)
data class PortfolioBalance(
val walletBalance: Lce<TokenListError, TotalFiatBalance>,
val userWallet: UserWallet,
val accountsBalance: AccountStatusList,
) {
val userWallet: UserWallet get() = accountsBalance.userWallet
val walletBalance get() = accountsBalance.totalFiatBalance
val userWalletId: UserWalletId get() = userWallet.walletId
}
sealed interface Mode {
@ -36,6 +37,9 @@ interface PortfolioFetcher {
data class Wallet(val walletId: UserWalletId) : Mode
}
/**
* @param[mode] supports runtime change [PortfolioFetcher.updateMode]
*/
interface Factory {
fun create(mode: Mode, scope: CoroutineScope): PortfolioFetcher
}

View file

@ -8,27 +8,55 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.wallet.UserWallet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* How to use
* 1) Create and keep instance of [PortfolioFetcher] and [PortfolioSelectorController] in your Feature Model
* 2) Provide them via [Params]
* 3) Now you have a bridge between your Feature and PortfolioSelector
* 4) Initial state is unselected. Select yourself [PortfolioSelectorController.selectAccount]
* or offer users to select
*
* 5) Listen [PortfolioSelectorController.selectedAccount] or [PortfolioSelectorController.selectedAccountWithData]
*
* Note:
* - Supports [PortfolioSelectorComponent.BottomSheet] and [PortfolioSelectorComponent.Content] modes
*/
interface PortfolioSelectorComponent : ComposableBottomSheetComponent, ComposableContentComponent {
val title: StateFlow<TextReference>
data class Params(
val onDismiss: () -> Unit,
val portfolioFetcher: PortfolioFetcher,
val controller: PortfolioSelectorController,
val bsCallback: BottomSheetCallback? = null,
)
interface BottomSheetCallback {
val onDismiss: () -> Unit
val onBack: () -> Unit
}
interface Factory : ComponentFactory<Params, PortfolioSelectorComponent>
}
/**
* How to use see [PortfolioSelectorComponent]
*
* if [isAccountMode] is false it's mean [selectedAccount] emit [AccountId] for Main account
*/
interface PortfolioSelectorController {
val isAccountMode: Flow<Boolean>
val selectedAccount: StateFlow<AccountId?>
val selectedAccount: Flow<AccountId?>
val selectedAccountSync: AccountId?
/**
* for some Feature specific filtering
* combine and update with your Feature data and [PortfolioFetcher.data]
*/
val isEnabled: MutableStateFlow<(UserWallet, AccountStatus) -> Boolean>
fun selectAccount(accountId: AccountId?)
fun selectedAccountWithData(portfolioFetcher: PortfolioFetcher): Flow<Pair<UserWallet, AccountStatus>?>

View file

@ -1,6 +1,8 @@
package com.tangem.features.account.archived
import com.tangem.common.ui.account.toUM
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
@ -11,6 +13,8 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.ToastMessage
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.usecase.ArchivedAccountList
import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase
@ -20,6 +24,7 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.account.archived.entity.AccountArchivedUM
import com.tangem.features.account.archived.entity.AccountArchivedUMBuilder
import com.tangem.features.account.createedit.error.AccountFeatureError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -37,6 +42,7 @@ internal class ArchivedAccountListModel @Inject constructor(
private val recoverCryptoPortfolioUseCase: RecoverCryptoPortfolioUseCase,
private val getArchivedAccountsUseCase: GetArchivedAccountsUseCase,
private val umBuilder: AccountArchivedUMBuilder,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : Model() {
private val params = paramsContainer.require<ArchivedAccountListComponent.Params>()
@ -103,11 +109,37 @@ internal class ArchivedAccountListModel @Inject constructor(
)
}
private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch {
private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch(dispatchers.default) {
recoverCryptoPortfolioUseCase(accountId)
.onLeft { Timber.e(it.toString()) }
.onRight { showSuccessRecoverMessage() }
router.pop()
.onLeft(::handleRecoverError)
.onRight {
showSuccessRecoverMessage()
router.pop()
}
}
private fun handleRecoverError(error: RecoverCryptoPortfolioUseCase.Error) {
if (error is RecoverCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet &&
error.cause is AccountList.Error.ExceedsMaxAccountsCount
) {
// TODO("account") show alert that max accounts count reached
// https://www.figma.com/design/09KKG4ZVuFDZhj8WLv5rGJ/%F0%9F%9A%A7-App-experience?node-id=24765-180563&t=vk6TCy4MkYol1cPb-4
return
}
val featureError = AccountFeatureError.ArchivedAccountList.FailedToRecoverAccount(cause = error)
logError(error = featureError)
messageSender.showErrorDialog(universalError = featureError, onDismiss = router::pop)
}
private fun logError(error: AccountFeatureError, params: Map<String, String> = mapOf()) {
val exception = IllegalStateException(error.toString())
Timber.e(exception)
analyticsExceptionHandler.sendException(
event = ExceptionAnalyticsEvent(exception = exception, params = params),
)
}
private fun showSuccessRecoverMessage() {

View file

@ -1,25 +1,18 @@
package com.tangem.features.account.archived.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.account.archived.ArchivedAccountListModel
import com.tangem.features.account.archived.DefaultArchivedAccountListComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
@InstallIn(ModelComponent::class)
internal interface AccountArchivedModule {
@Binds
fun bindArchivedAccountListComponentFactory(
impl: DefaultArchivedAccountListComponent.Factory,
): ArchivedAccountListComponent.Factory
@Binds
@IntoMap
@ClassKey(ArchivedAccountListModel::class)

View file

@ -1,25 +1,18 @@
package com.tangem.features.account.createedit.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.createedit.AccountCreateEditModel
import com.tangem.features.account.createedit.DefaultAccountCreateEditComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
@InstallIn(ModelComponent::class)
internal interface AccountCreateEditModule {
@Binds
fun bindAccountCreateEditComponentFactory(
impl: DefaultAccountCreateEditComponent.Factory,
): AccountCreateEditComponent.Factory
@Binds
@IntoMap
@ClassKey(AccountCreateEditModel::class)

View file

@ -13,7 +13,10 @@ import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase
import com.tangem.domain.models.PortfolioId
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.portfolioIcon
import com.tangem.features.account.details.entity.AccountDetailsUM
@ -30,6 +33,7 @@ internal class AccountDetailsModel @Inject constructor(
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
private val archiveCryptoPortfolioUseCase: ArchiveCryptoPortfolioUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
) : Model() {
private val params = paramsContainer.require<AccountDetailsComponent.Params>()
@ -42,8 +46,11 @@ internal class AccountDetailsModel @Inject constructor(
}
private fun onManageTokensClick() {
// todo account add account param
router.push(AppRoute.ManageTokens(source = AppRoute.ManageTokens.Source.SETTINGS))
val route = AppRoute.ManageTokens(
source = AppRoute.ManageTokens.Source.SETTINGS,
portfolioId = PortfolioId(params.account.accountId),
)
router.push(route)
}
private fun onArchiveAccountClick() {
@ -89,6 +96,8 @@ internal class AccountDetailsModel @Inject constructor(
)
}
}
val isMultiCurrency = getUserWalletUseCase(params.account.accountId.userWalletId)
.getOrNull()?.isMultiCurrency ?: false
return AccountDetailsUM(
accountName = params.account.accountName.toUM().value,
accountIcon = params.account.portfolioIcon.toUM(),
@ -96,6 +105,7 @@ internal class AccountDetailsModel @Inject constructor(
onAccountEditClick = ::onEditAccountClick,
onManageTokensClick = ::onManageTokensClick,
archiveMode = archiveMode,
isManageTokensAvailable = isMultiCurrency,
)
}
}

View file

@ -1,25 +1,18 @@
package com.tangem.features.account.details.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.details.AccountDetailsModel
import com.tangem.features.account.details.DefaultAccountDetailsComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
@InstallIn(ModelComponent::class)
internal interface AccountDetailsModule {
@Binds
fun bindAccountDetailsComponentFactory(
impl: DefaultAccountDetailsComponent.Factory,
): AccountDetailsComponent.Factory
@Binds
@IntoMap
@ClassKey(AccountDetailsModel::class)

View file

@ -7,6 +7,7 @@ internal data class AccountDetailsUM(
val accountName: TextReference,
val accountIcon: CryptoPortfolioIconUM,
val archiveMode: ArchiveMode,
val isManageTokensAvailable: Boolean,
val onCloseClick: () -> Unit,
val onAccountEditClick: () -> Unit,
val onManageTokensClick: () -> Unit,

View file

@ -22,7 +22,6 @@ import com.tangem.common.ui.R
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.AccountRow
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
@ -49,6 +48,7 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier =
)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = TangemTheme.dimens.spacing16)
@ -61,21 +61,22 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier =
style = TangemTheme.typography.h1,
color = TangemTheme.colors.text.primary1,
)
SpacerH16()
AccountRow(state)
SpacerH16()
ManageTokensRow(state)
if (state.isManageTokensAvailable) {
ManageTokensRow(state)
}
when (state.archiveMode) {
is AccountDetailsUM.ArchiveMode.Available -> {
SpacerH16()
ArchiveAccountRow(state.archiveMode)
SpacerH(8.dp)
Text(
modifier = Modifier.padding(horizontal = 12.dp),
text = stringResourceSafe(R.string.account_details_archive_description),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
Column {
ArchiveAccountRow(state.archiveMode)
SpacerH(8.dp)
Text(
modifier = Modifier.padding(horizontal = 12.dp),
text = stringResourceSafe(R.string.account_details_archive_description),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
AccountDetailsUM.ArchiveMode.None -> Unit
}
@ -186,10 +187,12 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountD
),
accountName = stringReference(accountName),
accountIcon = portfolioIcon,
isManageTokensAvailable = true,
)
add(first)
portfolioIcon = AccountIconPreviewData.randomAccountIcon(letter = true)
add(first.copy(accountIcon = portfolioIcon))
add(first.copy(archiveMode = AccountDetailsUM.ArchiveMode.None))
add(first.copy(isManageTokensAvailable = false))
},
)

View file

@ -1,8 +1,14 @@
package com.tangem.features.account.di
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.account.PortfolioSelectorController
import com.tangem.features.account.archived.DefaultArchivedAccountListComponent
import com.tangem.features.account.createedit.DefaultAccountCreateEditComponent
import com.tangem.features.account.details.DefaultAccountDetailsComponent
import com.tangem.features.account.fetcher.DefaultPortfolioFetcher
import com.tangem.features.account.selector.DefaultPortfolioSelectorComponent
import com.tangem.features.account.selector.DefaultPortfolioSelectorController
@ -25,4 +31,19 @@ internal interface AccountFeatureModule {
fun bindPortfolioSelectorComponentFactory(
impl: DefaultPortfolioSelectorComponent.Factory,
): PortfolioSelectorComponent.Factory
@Binds
fun bindAccountCreateEditComponentFactory(
impl: DefaultAccountCreateEditComponent.Factory,
): AccountCreateEditComponent.Factory
@Binds
fun bindAccountDetailsComponentFactory(
impl: DefaultAccountDetailsComponent.Factory,
): AccountDetailsComponent.Factory
@Binds
fun bindArchivedAccountListComponentFactory(
impl: DefaultArchivedAccountListComponent.Factory,
): ArchivedAccountListComponent.Factory
}

View file

@ -7,7 +7,6 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.account.PortfolioFetcher.*
@ -23,7 +22,6 @@ import kotlinx.coroutines.flow.*
internal class DefaultPortfolioFetcher @AssistedInject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val getWallets: GetWalletsUseCase,
dispatchers: CoroutineDispatcherProvider,
@ -80,14 +78,8 @@ internal class DefaultPortfolioFetcher @AssistedInject constructor(
return combine(balanceFlows) { pairs -> pairs.toMap() }
}
private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow<Pair<UserWallet, PortfolioBalance>> = combine(
flow = accountStatusListFlow(wallet),
flow2 = getWalletTotalBalanceUseCase(wallet.walletId),
transform = { accountStatusList, walletBalance ->
val portfolioBalance = PortfolioBalance(walletBalance, accountStatusList)
wallet to portfolioBalance
},
)
private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow<Pair<UserWallet, PortfolioBalance>> =
accountStatusListFlow(wallet).map { wallet to PortfolioBalance(wallet, it) }
private fun accountStatusListFlow(wallet: UserWallet): Flow<AccountStatusList> =
singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(wallet.walletId))

View file

@ -31,13 +31,13 @@ internal class DefaultPortfolioSelectorComponent @AssistedInject constructor(
.stateIn(componentScope, SharingStarted.Lazily, model.state.value.title)
override fun dismiss() {
params.onDismiss()
params.bsCallback?.onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
PortfolioSelectorBS(state, onDismiss = ::dismiss)
PortfolioSelectorBS(state = state, onDismiss = ::dismiss, onBack = { params.bsCallback?.onBack() })
}
@Composable

View file

@ -4,23 +4,33 @@ import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.account.PortfolioSelectorController
import com.tangem.features.account.PortfolioFetcher
import kotlinx.coroutines.flow.*
import com.tangem.features.account.PortfolioSelectorController
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
internal class DefaultPortfolioSelectorController @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
) : PortfolioSelectorController {
private val _selectedAccount: MutableStateFlow<AccountId?> = MutableStateFlow(null)
private val _selectedAccount: MutableSharedFlow<AccountId?> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val isAccountMode: Flow<Boolean> by lazy { isAccountsModeEnabledUseCase() }
// without StateFlow and distinctUntilChanged to allow reselect and correct navigation
override val selectedAccount: Flow<AccountId?> get() = _selectedAccount
override val selectedAccountSync: AccountId? get() = _selectedAccount.replayCache.firstOrNull()
override val selectedAccount: StateFlow<AccountId?> get() = _selectedAccount
override val isEnabled: MutableStateFlow<(UserWallet, AccountStatus) -> Boolean> = MutableStateFlow { _, _ -> true }
override fun selectAccount(accountId: AccountId?) {
_selectedAccount.update { accountId }
_selectedAccount.tryEmit(accountId)
}
override fun selectedAccountWithData(portfolioFetcher: PortfolioFetcher): Flow<Pair<UserWallet, AccountStatus>?> =

View file

@ -23,7 +23,6 @@ import com.tangem.operations.attestation.ArtworkSize
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ -45,9 +44,11 @@ internal class PortfolioSelectorModel @Inject constructor(
init {
combine(
flow = isAccountsModeEnabledUseCase(),
flow2 = loadBalanceWithArtwork(),
transform = { isAccountsMode, (portfolioData, artworks) ->
val uiList = buildUiList(isAccountsMode, portfolioData, artworks)
flow2 = balanceFetcher.data,
flow3 = walletImageFetcher.allWallets(ArtworkSize.SMALL),
flow4 = selectorController.isEnabled,
transform = { isAccountsMode, portfolioData, artworks, isEnabled ->
val uiList = buildUiList(isAccountsMode, portfolioData, artworks, isEnabled)
val title = when (isAccountsMode) {
true -> resourceReference(R.string.common_choose_account)
false -> resourceReference(R.string.common_choose_wallet)
@ -66,24 +67,25 @@ internal class PortfolioSelectorModel @Inject constructor(
isAccountsMode: Boolean,
portfolioData: PortfolioFetcher.Data,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
isEnabled: (UserWallet, AccountStatus) -> Boolean,
): List<PortfolioSelectorItemUM> = when (isAccountsMode) {
true -> buildAccountsList(portfolioData, artworks)
false -> buildWalletList(portfolioData, artworks)
true -> buildAccountsList(portfolioData, artworks, isEnabled)
false -> buildWalletList(portfolioData, artworks, isEnabled)
}
private fun buildWalletList(
portfolioData: PortfolioFetcher.Data,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
isEnabled: (UserWallet, AccountStatus) -> Boolean,
): List<PortfolioSelectorItemUM> = buildList {
val appCurrency = portfolioData.appCurrency
val isBalanceHidden = portfolioData.isBalanceHidden
val lockedWallets = mutableListOf<PortfolioSelectorItemUM>()
portfolioData.balances.forEach { wallet, portfolio ->
val balance = portfolio.walletBalance.getOrNull()
val balance = portfolio.walletBalance
val walletItemUM = UserWalletItemUMConverter(
onClick = {
// todo account
// selectorController.selectAccount(portfolio.accountsBalance.mainAccount)
selectorController.selectAccount(portfolio.accountsBalance.mainAccount.account.accountId)
},
appCurrency = appCurrency,
balance = balance,
@ -92,7 +94,10 @@ internal class PortfolioSelectorModel @Inject constructor(
isAuthMode = false,
).convert(wallet)
if (walletItemUM.isEnabled) {
add(PortfolioSelectorItemUM.Portfolio(walletItemUM))
val isEnabledByFeature = isEnabled(wallet, portfolio.accountsBalance.mainAccount)
val finalWalletItemUM =
if (isEnabledByFeature) walletItemUM else walletItemUM.copy(isEnabled = false)
add(PortfolioSelectorItemUM.Portfolio(finalWalletItemUM))
} else {
lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM))
}
@ -110,16 +115,16 @@ internal class PortfolioSelectorModel @Inject constructor(
private fun buildAccountsList(
portfolioData: PortfolioFetcher.Data,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
isEnabled: (UserWallet, AccountStatus) -> Boolean,
): List<PortfolioSelectorItemUM> = buildList {
val appCurrency = portfolioData.appCurrency
val isBalanceHidden = portfolioData.isBalanceHidden
val lockedWallets = mutableListOf<PortfolioSelectorItemUM>()
portfolioData.balances.forEach { wallet, portfolio ->
val balance = portfolio.walletBalance.getOrNull()
val balance = portfolio.walletBalance
val walletItemUM = UserWalletItemUMConverter(
onClick = {
// todo account
// selectorController.selectAccount(portfolio.accountsBalance.mainAccount)
selectorController.selectAccount(portfolio.accountsBalance.mainAccount.account.accountId)
},
appCurrency = appCurrency,
balance = balance,
@ -138,8 +143,8 @@ internal class PortfolioSelectorModel @Inject constructor(
)
add(walletTitle)
add(PortfolioSelectorItemUM.Portfolio(walletItemUM))
portfolio.accountsBalance.accountStatuses.forEach { accountStatus ->
val isEnabledByFeature = isEnabled(wallet, accountStatus)
val account = accountStatus.account
val accountBalance = when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.tokenList.totalFiatBalance
@ -148,6 +153,7 @@ internal class PortfolioSelectorModel @Inject constructor(
onClick = { selectorController.selectAccount(account.accountId) },
appCurrency = appCurrency,
accountBalance = accountBalance,
isEnabled = isEnabledByFeature,
isBalanceHidden = isBalanceHidden,
).convert(account)
add(PortfolioSelectorItemUM.Portfolio(accountItemUM))
@ -163,22 +169,6 @@ internal class PortfolioSelectorModel @Inject constructor(
}
}
private fun loadBalanceWithArtwork():
Flow<Pair<PortfolioFetcher.Data, Map<UserWalletId, UserWalletItemUM.ImageState>>> {
val wallets = Channel<Set<UserWallet>>()
val portfolioFlow = balanceFetcher.data
.onEach { wallets.trySend(it.balances.keys) }
val artworksFlow = wallets.receiveAsFlow()
.distinctUntilChanged()
.flatMapLatest { walletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) }
return combine(
flow = portfolioFlow,
flow2 = artworksFlow,
) { portfolioData, artworks -> portfolioData to artworks }
}
private fun emptyState() = PortfolioSelectorUM(
items = persistentListOf(),
title = TextReference.EMPTY,

View file

@ -19,21 +19,26 @@ import com.tangem.features.account.impl.R
import com.tangem.features.account.selector.entity.PortfolioSelectorUM
@Composable
internal fun PortfolioSelectorBS(state: PortfolioSelectorUM, onDismiss: () -> Unit, modifier: Modifier = Modifier) {
internal fun PortfolioSelectorBS(
state: PortfolioSelectorUM,
onDismiss: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
onBack = onDismiss,
onBack = onBack,
scrollableContent = false,
containerColor = TangemTheme.colors.background.secondary,
containerColor = TangemTheme.colors.background.tertiary,
title = {
TangemModalBottomSheetTitle(
title = state.title,
startIconRes = R.drawable.ic_back_24,
onStartClick = onDismiss,
onStartClick = onBack,
)
},
content = {
@ -54,7 +59,8 @@ private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::cla
PortfolioSelectorBS(
state = params,
onDismiss = {},
modifier = Modifier.background(color = TangemTheme.colors.background.secondary),
modifier = Modifier,
onBack = {},
)
}
}

View file

@ -2,8 +2,10 @@ package com.tangem.features.account.selector.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
@ -18,9 +20,10 @@ 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.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.common.ui.userwallet.UserWalletItemRow
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -66,12 +69,16 @@ internal fun PortfolioSelectorContent(
}
when (item) {
is PortfolioSelectorItemUM.Portfolio -> UserWalletItem(
is PortfolioSelectorItemUM.Portfolio -> UserWalletItemRow(
state = item.item,
modifier = offsetModifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size68)
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.primary)
.let { if (!item.item.isEnabled) it.alpha(DISABLED_WALLET_ALPHA) else it },
.background(TangemTheme.colors.background.action)
.clickable(enabled = item.item.isEnabled, onClick = item.item.onClick)
.padding(all = TangemTheme.dimens.spacing12)
.conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) },
)
is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow(
model = item,
@ -103,7 +110,7 @@ private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::cla
TangemThemePreview {
PortfolioSelectorContent(
state = params,
modifier = Modifier.background(color = TangemTheme.colors.background.secondary),
modifier = Modifier.background(color = TangemTheme.colors.background.tertiary),
)
}
}
@ -125,9 +132,11 @@ internal object PortfolioSelectorPreviewData {
name = accountName,
icon = AccountIconPreviewData.randomAccountIcon(),
),
label = null,
)
private val lockedAccountItem: UserWalletItemUM
get() = accountItem.copy(isEnabled = false)
private val walletItem: UserWalletItemUM
get() = UserWalletItemUM(
id = UserWalletId(UUID.randomUUID().toString().encodeToByteArray()),
@ -137,7 +146,6 @@ internal object PortfolioSelectorPreviewData {
isEnabled = true,
onClick = { },
imageState = ImageState.MobileWallet,
label = null,
)
private val lockedWalletItem: UserWalletItemUM
@ -152,7 +160,7 @@ internal object PortfolioSelectorPreviewData {
accountItem
.let { PortfolioSelectorItemUM.Portfolio(it) }
.let(::add)
accountItem
lockedAccountItem
.let { PortfolioSelectorItemUM.Portfolio(it) }
.let(::add)
PortfolioSelectorItemUM.GroupTitle(

View file

@ -1,72 +1,80 @@
package com.tangem.features.createwalletselection
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic.SignedIn
import com.tangem.core.analytics.models.Basic.SignedIn.SignInType
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.R
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.analytics.ParamCardCurrencyConverter
import com.tangem.domain.card.analytics.Shop
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM
import com.tangem.features.createwalletselection.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val HIDE_PROGRESS_DELAY = 400L
@Suppress("LongParameterList")
@ModelScoped
internal class CreateWalletSelectionModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val scanCardProcessor: ScanCardProcessor,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsRepository: SettingsRepository,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val userWalletsListRepository: UserWalletsListRepository,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
internal val uiState: StateFlow<CreateWalletSelectionUM>
field = MutableStateFlow(
CreateWalletSelectionUM(
onBackClick = { router.pop() },
onMobileWalletClick = ::onMobileWalletClick,
onHardwareWalletClick = ::onHardwareWalletClick,
onScanClick = ::onScanClick,
blocks = persistentListOf(
CreateWalletSelectionUM.Block(
title = resourceReference(R.string.wallet_create_hardware_title),
titleLabel = LabelUM(
text = resourceReference(R.string.common_recommended),
style = LabelStyle.ACCENT,
),
description = resourceReference(R.string.wallet_add_hardware_description),
features = persistentListOf(
CreateWalletSelectionUM.Feature(
iconResId = R.drawable.ic_add_wallet_16,
title = resourceReference(R.string.wallet_add_hardware_info_create),
),
CreateWalletSelectionUM.Feature(
iconResId = R.drawable.ic_import_seed_16,
title = resourceReference(R.string.wallet_add_import_seed_phrase),
),
),
onClick = ::onHardwareWalletClick,
),
CreateWalletSelectionUM.Block(
title = resourceReference(R.string.wallet_create_mobile_title),
titleLabel = null,
description = resourceReference(R.string.wallet_add_mobile_description),
features = persistentListOf(
CreateWalletSelectionUM.Feature(
iconResId = R.drawable.ic_mobile_wallet_16,
title = resourceReference(R.string.hw_create_title),
),
CreateWalletSelectionUM.Feature(
iconResId = R.drawable.ic_import_seed_16,
title = resourceReference(R.string.wallet_add_import_seed_phrase),
),
),
onClick = ::onMobileWalletClick,
),
),
onBuyClick = ::onBuyClick,
),
)
@ -86,6 +94,10 @@ internal class CreateWalletSelectionModel @Inject constructor(
}
private fun onHardwareWalletClick() {
// TODO [REDACTED_TASK_KEY]
}
private fun onBuyClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards)
analyticsEventHandler.send(Shop.ScreenOpened)
modelScope.launch {
@ -93,113 +105,6 @@ internal class CreateWalletSelectionModel @Inject constructor(
}
}
private fun onScanClick() {
analyticsEventHandler.send(IntroductionProcess.ButtonScanCard)
scanCard()
}
private fun scanCard() {
modelScope.launch {
setLoading(true)
val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes()
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = shouldSaveAccessCodes,
)
val analyticsSource = AnalyticsParam.ScreensSources.Intro
scanCardProcessor.scan(
analyticsSource = analyticsSource,
onProgressStateChange = { showProgress ->
if (!showProgress) {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
} else {
setLoading(true)
}
},
onFailure = { error ->
handleScanError(error)
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
},
onSuccess = { scanResponse ->
proceedWithScanResponse(scanResponse)
},
)
}
}
private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) {
val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build()
if (userWallet == null) {
Timber.e("User wallet not created")
setLoading(false)
return
}
saveWalletUseCase(userWallet = userWallet).fold(
ifLeft = {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
when (it) {
is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet")
is SaveWalletError.WalletAlreadySaved -> {
userWalletsListRepository.unlock(
userWalletId = userWallet.walletId,
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse),
).onRight {
appRouter.replaceAll(AppRoute.Wallet)
}
}
}
},
ifRight = {
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse)
appRouter.replaceAll(AppRoute.Wallet)
},
)
}
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
SignedIn(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListRepository.userWalletsSync().size.toString(),
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
}
private fun setLoading(isLoading: Boolean) {
uiState.update { it.copy(isScanInProgress = isLoading) }
}
fun handleScanError(error: TangemError) {
when (error) {
is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable()
is TangemSdkError -> Timber.e(error, "Scan error occurred")
else -> Timber.e(error, "Error happened")
}
}
private fun handleNfcFeatureUnavailable() {
uiMessageSender.send(
message = DialogMessage(
message = resourceReference(R.string.nfc_error_unavailable),
title = resourceReference(id = R.string.common_error),
),
)
}
companion object {
private const val SHOW_ALREADY_HAVE_WALLET_DELAY = 3000L
}

View file

@ -1,11 +1,26 @@
package com.tangem.features.createwalletselection.entity
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class CreateWalletSelectionUM(
val isScanInProgress: Boolean = false,
val hardwareWalletPrice: String = "$54.90",
val showAlreadyHaveWallet: Boolean = false,
val blocks: ImmutableList<Block>,
val onBackClick: () -> Unit,
val onMobileWalletClick: () -> Unit,
val onHardwareWalletClick: () -> Unit,
val onScanClick: () -> Unit,
)
val onBuyClick: () -> Unit,
) {
data class Block(
val title: TextReference,
val titleLabel: LabelUM?,
val description: TextReference,
val features: ImmutableList<Feature>,
val onClick: () -> Unit,
)
data class Feature(
val iconResId: Int,
val title: TextReference,
)
}

View file

@ -10,23 +10,25 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM
import com.tangem.features.createwalletselection.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@ -34,7 +36,7 @@ import com.tangem.features.createwalletselection.impl.R
internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.primary)
.background(TangemTheme.colors.background.secondary)
.fillMaxSize()
.systemBarsPadding(),
) {
@ -42,7 +44,7 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi
modifier = Modifier
.statusBarsPadding(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = TangemTheme.colors.background.primary,
containerColor = TangemTheme.colors.background.secondary,
),
navigationIcon = {
IconButton(onClick = state.onBackClick) {
@ -58,7 +60,7 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi
Text(
modifier = Modifier
.padding(16.dp),
text = stringResourceSafe(R.string.wallet_create_nav_info_title),
text = stringResourceSafe(R.string.wallet_add_support_title),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
@ -78,60 +80,33 @@ internal fun CreateWalletSelectionContent(state: CreateWalletSelectionUM, modifi
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = stringResourceSafe(R.string.wallet_create_title),
.padding(
start = 16.dp,
end = 16.dp,
bottom = 24.dp,
),
text = stringResourceSafe(R.string.wallet_add_common_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
WalletBlock(
modifier = Modifier
.padding(top = 24.dp),
title = stringResourceSafe(R.string.wallet_create_mobile_title),
description = stringResourceSafe(R.string.wallet_create_mobile_description),
badge = {
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.field.focused,
shape = TangemTheme.shapes.roundedCorners8,
)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
text = stringResourceSafe(R.string.common_free),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.secondary,
)
}
},
onClick = state.onMobileWalletClick,
)
WalletBlock(
title = stringResourceSafe(R.string.wallet_create_hardware_title),
description = stringResourceSafe(R.string.wallet_create_hardware_description),
badge = {
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.text.accent.copy(alpha = 0.1f),
shape = TangemTheme.shapes.roundedCorners8,
)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
text = stringResourceSafe(R.string.wallet_create_hardware_badge, state.hardwareWalletPrice),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.accent,
)
}
},
onClick = state.onHardwareWalletClick,
)
state.blocks.forEach { block ->
WalletBlock(
modifier = Modifier
.padding(top = 8.dp),
title = block.title.resolveReference(),
description = block.description.resolveReference(),
features = block.features,
badge = block.titleLabel?.let {
{ Label(it) }
},
onClick = block.onClick,
)
}
}
AnimatedVisibility(state.showAlreadyHaveWallet) {
AlreadyHaveTangemWalletBlock(
onScanClick = state.onScanClick,
onBuyClick = state.onBuyClick,
isScanInProgress = state.isScanInProgress,
)
}
@ -143,8 +118,9 @@ private fun WalletBlock(
title: String,
description: String,
onClick: () -> Unit,
features: ImmutableList<CreateWalletSelectionUM.Feature>,
modifier: Modifier = Modifier,
badge: @Composable () -> Unit,
badge: @Composable (() -> Unit)? = null,
) {
Column(
modifier = modifier
@ -152,11 +128,14 @@ private fun WalletBlock(
.padding(top = 8.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(
color = TangemTheme.colors.field.primary,
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.clickable(onClick = onClick)
.padding(16.dp),
.padding(
horizontal = 16.dp,
vertical = 12.dp,
),
) {
Row {
Text(
@ -167,7 +146,7 @@ private fun WalletBlock(
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
badge()
badge?.invoke()
}
Text(
modifier = Modifier
@ -176,24 +155,57 @@ private fun WalletBlock(
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
if (features.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(top = 12.dp),
thickness = 0.5.dp,
color = TangemTheme.colors.stroke.primary,
)
features.forEach {
Feature(
feature = it,
modifier = Modifier
.padding(top = 12.dp),
)
}
}
}
}
@Composable
private fun Feature(feature: CreateWalletSelectionUM.Feature, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size16),
painter = painterResource(id = feature.iconResId),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
Text(
modifier = Modifier
.weight(1f, fill = false)
.padding(start = 6.dp),
text = feature.title.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
)
}
}
@Composable
private fun AlreadyHaveTangemWalletBlock(
onScanClick: () -> Unit,
onBuyClick: () -> Unit,
isScanInProgress: Boolean,
modifier: Modifier = Modifier,
) {
var buttonWidth by remember { mutableStateOf(0) }
val density = LocalDensity.current
Row(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
.background(
color = TangemTheme.colors.field.primary,
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(
@ -206,30 +218,16 @@ private fun AlreadyHaveTangemWalletBlock(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
text = stringResourceSafe(R.string.wallet_create_scan_question),
text = stringResourceSafe(R.string.wallet_add_hardware_purchase),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
TangemButton(
modifier = Modifier
.conditional(buttonWidth > 0) {
width(with(density) { buttonWidth.toDp() })
}
.onGloballyPositioned { coordinates ->
if (buttonWidth == 0) {
buttonWidth = coordinates.size.width
}
},
text = stringResourceSafe(R.string.wallet_create_scan_title),
onClick = onScanClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24),
SecondaryButton(
text = stringResourceSafe(R.string.wallet_import_buy_title),
onClick = onBuyClick,
size = TangemButtonSize.RoundedAction,
showProgress = isScanInProgress,
colors = TangemButtonsDefaults.secondaryButtonColors,
textStyle = TangemTheme.typography.subtitle1,
enabled = true,
animateContentChange = true,
)
}
}
@ -241,11 +239,45 @@ private fun PreviewCreateWalletContent() {
TangemThemePreview {
CreateWalletSelectionContent(
state = CreateWalletSelectionUM(
showAlreadyHaveWallet = true,
onBackClick = {},
onMobileWalletClick = {},
onHardwareWalletClick = {},
onScanClick = {},
onBackClick = { },
blocks = persistentListOf(
CreateWalletSelectionUM.Block(
title = resourceReference(R.string.wallet_create_hardware_title),
titleLabel = LabelUM(
text = resourceReference(R.string.common_recommended),
style = LabelStyle.ACCENT,
),
description = resourceReference(R.string.wallet_add_hardware_description),
features = persistentListOf(
CreateWalletSelectionUM.Feature(
iconResId = R.drawable.ic_add_wallet_16,
title = resourceReference(R.string.wallet_add_hardware_info_create),
),
CreateWalletSelectionUM.Feature(
iconResId = R.drawable.ic_import_seed_16,
title = resourceReference(R.string.wallet_add_import_seed_phrase),
),
),
onClick = { },
),
CreateWalletSelectionUM.Block(
title = resourceReference(R.string.wallet_create_mobile_title),
titleLabel = null,
description = resourceReference(R.string.wallet_add_mobile_description),
features = persistentListOf(
CreateWalletSelectionUM.Feature(
iconResId = R.drawable.ic_mobile_wallet_16,
title = resourceReference(R.string.hw_create_title),
),
CreateWalletSelectionUM.Feature(
iconResId = R.drawable.ic_import_seed_16,
title = resourceReference(R.string.wallet_add_import_seed_phrase),
),
),
onClick = { },
),
),
onBuyClick = { },
),
)
}

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,21 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.createwalletstart.api"
}
dependencies {
/* Project - Domain */
implementation(projects.domain.models)
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/* Compose */
implementation(deps.compose.runtime)
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.createwalletstart
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface CreateWalletStartComponent : ComposableContentComponent {
data class Params(
val mode: Mode,
)
enum class Mode {
ColdWallet,
HotWallet,
}
interface Factory : ComponentFactory<Params, CreateWalletStartComponent>
}

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,72 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.createwalletstart.impl"
}
dependencies {
/** Api */
implementation(projects.features.createWalletStart.api)
/** Project - Domain */
implementation(projects.domain.card)
implementation(projects.domain.settings)
implementation(projects.domain.wallets)
implementation(projects.domain.models)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.res)
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.datasource)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)
/** Tangem libraries */
implementation(projects.libs.tangemSdkApi)
implementation(tangemDeps.card.core)
implementation(tangemDeps.card.android) {
exclude(module = "joda-time")
}
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)
implementation(deps.lifecycle.runtime.ktx)
/** Compose libraries */
implementation(deps.compose.material3)
implementation(deps.compose.animation)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.lottie.compose)
implementation(deps.decompose.ext.compose)
implementation(deps.androidx.activity.compose)
implementation(deps.androidx.datastore)
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
implementation(deps.firebase.crashlytics)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,244 @@
package com.tangem.features.createwalletstart
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic.SignedIn
import com.tangem.core.analytics.models.Basic.SignedIn.SignInType
import com.tangem.core.decompose.di.GlobalUiMessageSender
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.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.analytics.ParamCardCurrencyConverter
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.createwalletstart.entity.CreateWalletStartUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val HIDE_PROGRESS_DELAY = 400L
@Suppress("LongParameterList")
@ModelScoped
internal class CreateWalletStartModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val scanCardProcessor: ScanCardProcessor,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsRepository: SettingsRepository,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
) : Model() {
private val params = paramsContainer.require<CreateWalletStartComponent.Params>()
internal val uiState: StateFlow<CreateWalletStartUM>
field = MutableStateFlow(
when (params.mode) {
CreateWalletStartComponent.Mode.ColdWallet -> CreateWalletStartUM(
title = resourceReference(R.string.common_tangem_wallet),
description = resourceReference(R.string.welcome_create_wallet_hardware_description),
featureItems = persistentListOf(
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_shield_check_16,
text = resourceReference(R.string.welcome_create_wallet_feature_class),
),
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_flash_16,
text = resourceReference(R.string.welcome_create_wallet_feature_delivery),
),
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_sparkles_16,
text = resourceReference(R.string.welcome_create_wallet_feature_use),
),
),
imageResId = R.drawable.img_hardware_wallet,
showScanSecondaryButton = true,
onPrimaryButtonClick = ::onBuyClick,
primaryButtonText = resourceReference(R.string.details_buy_wallet),
otherMethodTitle = resourceReference(R.string.welcome_create_wallet_mobile_title),
otherMethodDescription = resourceReference(R.string.welcome_create_wallet_mobile_description),
otherMethodClick = ::onStartWithMobileWalletClick,
onBackClick = { router.pop() },
onScanClick = ::onScanClick,
isScanInProgress = false,
)
CreateWalletStartComponent.Mode.HotWallet -> CreateWalletStartUM(
title = resourceReference(R.string.hw_mobile_wallet),
description = resourceReference(R.string.welcome_create_wallet_mobile_description_full),
featureItems = persistentListOf(
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_shield_check_16,
text = resourceReference(R.string.welcome_create_wallet_feature_seamless),
),
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_flash_16,
text = resourceReference(R.string.welcome_create_wallet_feature_one_tap),
),
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_stack_fill_new_16,
text = resourceReference(R.string.welcome_create_wallet_feature_assets),
),
),
imageResId = R.drawable.img_mobile_wallet,
showScanSecondaryButton = false,
onPrimaryButtonClick = ::onStartWithMobileWalletClick,
primaryButtonText = resourceReference(R.string.welcome_create_wallet_mobile_title),
otherMethodTitle = resourceReference(R.string.welcome_create_wallet_use_hardware_title),
otherMethodDescription = resourceReference(R.string.welcome_create_wallet_use_hardware_description),
otherMethodClick = ::onBuyClick,
onBackClick = { router.pop() },
onScanClick = ::onScanClick,
isScanInProgress = false,
)
},
)
private fun onScanClick() {
scanCard()
}
private fun onStartWithMobileWalletClick() {
router.push(AppRoute.CreateMobileWallet)
}
private fun onBuyClick() {
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun scanCard() {
modelScope.launch {
setLoading(true)
val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes()
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = shouldSaveAccessCodes,
)
val analyticsSource = AnalyticsParam.ScreensSources.Intro
scanCardProcessor.scan(
analyticsSource = analyticsSource,
onProgressStateChange = { showProgress ->
if (!showProgress) {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
} else {
setLoading(true)
}
},
onFailure = { error ->
handleScanError(error)
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
},
onSuccess = { scanResponse ->
proceedWithScanResponse(scanResponse)
},
)
}
}
private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) {
val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build()
if (userWallet == null) {
Timber.e("User wallet not created")
setLoading(false)
return
}
saveWalletUseCase(userWallet = userWallet).fold(
ifLeft = {
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
when (it) {
is SaveWalletError.DataError -> Timber.e(it.toString(), "Unable to save user wallet")
is SaveWalletError.WalletAlreadySaved -> {
userWalletsListRepository.unlock(
userWalletId = userWallet.walletId,
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse),
).onRight {
appRouter.replaceAll(AppRoute.Wallet)
}
}
}
},
ifRight = {
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse = scanResponse, isImported = userWallet.isImported)
appRouter.replaceAll(AppRoute.Wallet)
},
)
}
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
SignedIn(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListRepository.userWalletsSync().size.toString(),
isImported = isImported,
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
}
private fun setLoading(isLoading: Boolean) {
uiState.update { it.copy(isScanInProgress = isLoading) }
}
private fun handleScanError(error: TangemError) {
when (error) {
is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable()
is TangemSdkError -> Timber.e(error, "Scan error occurred")
else -> Timber.e(error, "Error happened")
}
}
private fun handleNfcFeatureUnavailable() {
uiMessageSender.send(
message = DialogMessage(
message = resourceReference(R.string.nfc_error_unavailable),
title = resourceReference(id = R.string.common_error),
),
)
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.features.createwalletstart
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.SystemBarsIconsDisposable
import com.tangem.core.ui.res.ForceDarkTheme
import com.tangem.features.createwalletstart.ui.CreateWalletStartContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultCreateWalletStartComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: CreateWalletStartComponent.Params,
) : CreateWalletStartComponent, AppComponentContext by context {
private val model: CreateWalletStartModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
SystemBarsIconsDisposable(darkIcons = false)
ForceDarkTheme {
CreateWalletStartContent(
state = state,
modifier = modifier,
)
}
}
@AssistedFactory
interface Factory : CreateWalletStartComponent.Factory {
override fun create(
context: AppComponentContext,
params: CreateWalletStartComponent.Params,
): DefaultCreateWalletStartComponent
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.createwalletstart.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.createwalletstart.CreateWalletStartComponent
import com.tangem.features.createwalletstart.CreateWalletStartModel
import com.tangem.features.createwalletstart.DefaultCreateWalletStartComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object CreateWalletStartModule
@Module
@InstallIn(SingletonComponent::class)
internal interface CreateWalletStartModuleBinds {
@Binds
@Singleton
fun bindCreateWalletStartComponentFactory(
impl: DefaultCreateWalletStartComponent.Factory,
): CreateWalletStartComponent.Factory
@Binds
@IntoMap
@ClassKey(CreateWalletStartModel::class)
fun bindCreateWalletStartModel(model: CreateWalletStartModel): Model
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.createwalletstart.entity
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class CreateWalletStartUM(
val title: TextReference,
val description: TextReference,
val featureItems: ImmutableList<FeatureItem>,
val imageResId: Int,
val isScanInProgress: Boolean,
val showScanSecondaryButton: Boolean,
val primaryButtonText: TextReference,
val onPrimaryButtonClick: () -> Unit,
val otherMethodDescription: TextReference,
val otherMethodTitle: TextReference,
val otherMethodClick: () -> Unit,
val onScanClick: () -> Unit,
val onBackClick: () -> Unit,
) {
data class FeatureItem(
val iconResId: Int,
val text: TextReference,
)
}

View file

@ -0,0 +1,486 @@
package com.tangem.features.createwalletstart.ui
import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButtonIconEnd
import com.tangem.core.ui.components.bottomFade
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.createwalletstart.entity.CreateWalletStartUM
import com.tangem.features.createwalletstart.impl.R
import kotlinx.collections.immutable.persistentListOf
import kotlin.math.max
@Suppress("LongMethod", "MagicNumber")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
internal fun CreateWalletStartContent(state: CreateWalletStartUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(
brush = Brush.verticalGradient(
listOf(
TangemColorPalette.Dark6,
TangemColorPalette.Black,
),
),
)
.fillMaxSize()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
navigationIcon = {
IconButton(onClick = state.onBackClick) {
Icon(
painter = painterResource(R.drawable.ic_back_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
}
},
title = { },
)
Box(
modifier = Modifier
.weight(1f)
.bottomFade(height = 24.dp),
) {
AdaptiveScrollableContent(
topContent = {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 32.dp,
top = 16.dp,
end = 32.dp,
),
text = state.title.resolveReference(),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 32.dp,
top = 8.dp,
end = 32.dp,
),
text = state.description.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 24.dp,
top = 16.dp,
end = 24.dp,
),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
state.featureItems.forEach {
FeatureItem(
iconResId = it.iconResId,
text = it.text,
)
}
}
},
imageContent = {
Image(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(
vertical = 12.dp,
horizontal = 16.dp,
),
painter = painterResource(id = state.imageResId),
contentDescription = null,
contentScale = ContentScale.Fit,
)
},
bottomContent = {
if (state.showScanSecondaryButton) {
SecondaryButtonIconEnd(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = stringResourceSafe(R.string.welcome_unlock_card),
onClick = state.onScanClick,
showProgress = state.isScanInProgress,
iconResId = R.drawable.ic_tangem_24,
)
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
top = 8.dp,
end = 16.dp,
),
text = state.primaryButtonText.resolveReference(),
onClick = state.onPrimaryButtonClick,
)
Row(
modifier = Modifier
.padding(
start = 16.dp,
top = 24.dp,
end = 16.dp,
),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DashedGradientLine(
modifier = Modifier
.weight(1f)
.height(16.dp),
)
Text(
text = stringResourceSafe(R.string.welcome_create_wallet_other_method),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
DashedGradientLine(
modifier = Modifier
.weight(1f)
.height(16.dp)
.scale(scaleX = -1f, scaleY = 1f),
)
}
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
top = 16.dp,
end = 16.dp,
),
text = state.otherMethodDescription.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
Row(
modifier = Modifier
.wrapContentWidth()
.clickable { state.otherMethodClick() }
.padding(
horizontal = 16.dp,
vertical = 12.dp,
),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = state.otherMethodTitle.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Icon(
painter = painterResource(id = R.drawable.ic_chevron_right_18x24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
}
},
minImageHeight = 160.dp,
)
}
if (!state.showScanSecondaryButton) {
FlowRow(
modifier = Modifier
.wrapContentWidth()
.padding(
start = 16.dp,
top = 24.dp,
end = 16.dp,
bottom = 8.dp,
),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = stringResourceSafe(R.string.welcome_create_wallet_already_have),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.secondary,
)
Spacer(modifier = Modifier.size(4.dp))
Row(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { state.onScanClick() },
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResourceSafe(R.string.wallet_create_scan_title),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
Spacer(modifier = Modifier.size(2.dp))
Icon(
modifier = Modifier.size(16.dp),
painter = painterResource(id = R.drawable.ic_tangem_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
}
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
private fun AdaptiveScrollableContent(
minImageHeight: Dp,
modifier: Modifier = Modifier,
topContent: @Composable () -> Unit,
imageContent: @Composable () -> Unit,
bottomContent: @Composable () -> Unit,
) {
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
) {
val density = LocalDensity.current
val viewportHeight = maxHeight
val minImageHeightPx = with(density) { minImageHeight.roundToPx() }
val viewportHeightPx = with(density) { viewportHeight.roundToPx() }
Layout(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
content = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
topContent()
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
imageContent()
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
bottomContent()
}
},
) { measurables, constraints ->
val topPlaceable = measurables[0].measure(
constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity),
)
val bottomPlaceable = measurables[2].measure(
constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity),
)
val imageIntrinsicHeight = measurables[1].maxIntrinsicHeight(constraints.maxWidth)
val availableHeightForImage = max(0, viewportHeightPx - topPlaceable.height - bottomPlaceable.height)
val targetImageHeight = when {
imageIntrinsicHeight < minImageHeightPx -> minImageHeightPx
imageIntrinsicHeight > availableHeightForImage -> max(minImageHeightPx, availableHeightForImage)
else -> imageIntrinsicHeight
}
val imagePlaceable = measurables[1].measure(
constraints.copy(
minHeight = targetImageHeight,
maxHeight = targetImageHeight,
),
)
val totalContentHeight = topPlaceable.height + imagePlaceable.height + bottomPlaceable.height
layout(constraints.maxWidth, totalContentHeight) {
var yOffset = 0
topPlaceable.placeRelative(0, yOffset)
yOffset += topPlaceable.height
imagePlaceable.placeRelative(0, yOffset)
yOffset += imagePlaceable.height
bottomPlaceable.placeRelative(0, yOffset)
}
}
}
}
@Composable
private fun DashedGradientLine(modifier: Modifier = Modifier) {
val density = LocalDensity.current
val strokeColor = TangemTheme.colors.stroke.primary
Canvas(modifier = modifier) {
val strokePx = with(density) { 4.dp.toPx() }
val dashPx = with(density) { 4.dp.toPx() }
val gapPx = with(density) { 8.dp.toPx() }
val width = size.width
val centerY = size.height / 2
val brush = Brush.linearGradient(
colors = listOf(strokeColor.copy(alpha = 0f), strokeColor),
start = Offset(0f, 0f),
end = Offset(width, 0f),
)
val pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashPx, gapPx), 0f)
drawLine(
brush = brush,
start = Offset(0f, centerY),
end = Offset(width, centerY),
strokeWidth = strokePx,
pathEffect = pathEffect,
cap = StrokeCap.Round,
)
}
}
@Composable
private fun FeatureItem(@DrawableRes iconResId: Int, text: TextReference) {
Row(
modifier = Modifier
.wrapContentWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
modifier = Modifier.size(16.dp),
painter = painterResource(iconResId),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
Text(
text = text.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.secondary,
)
}
}
private class CreateWalletStartStateProvider : CollectionPreviewParameterProvider<CreateWalletStartUM>(
collection = listOf(
CreateWalletStartUM(
title = resourceReference(R.string.common_tangem_wallet),
description = resourceReference(R.string.welcome_create_wallet_hardware_description),
featureItems = persistentListOf(
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_shield_check_16,
text = resourceReference(R.string.welcome_create_wallet_feature_class),
),
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_flash_16,
text = resourceReference(R.string.welcome_create_wallet_feature_delivery),
),
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_sparkles_16,
text = resourceReference(R.string.welcome_create_wallet_feature_use),
),
),
imageResId = R.drawable.img_hardware_wallet,
showScanSecondaryButton = true,
onPrimaryButtonClick = { },
primaryButtonText = resourceReference(R.string.details_buy_wallet),
otherMethodTitle = resourceReference(R.string.welcome_create_wallet_mobile_title),
otherMethodDescription = resourceReference(
R.string.welcome_create_wallet_mobile_description,
),
otherMethodClick = { },
onBackClick = { },
onScanClick = { },
isScanInProgress = false,
),
CreateWalletStartUM(
title = resourceReference(R.string.hw_mobile_wallet),
description = resourceReference(R.string.welcome_create_wallet_mobile_description_full),
featureItems = persistentListOf(
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_shield_check_16,
text = resourceReference(R.string.welcome_create_wallet_feature_seamless),
),
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_flash_16,
text = resourceReference(R.string.welcome_create_wallet_feature_one_tap),
),
CreateWalletStartUM.FeatureItem(
iconResId = R.drawable.ic_stack_fill_new_16,
text = resourceReference(R.string.welcome_create_wallet_feature_assets),
),
),
imageResId = R.drawable.img_mobile_wallet,
showScanSecondaryButton = false,
onPrimaryButtonClick = { },
primaryButtonText = resourceReference(R.string.welcome_create_wallet_mobile_title),
otherMethodTitle = resourceReference(R.string.welcome_create_wallet_use_hardware_title),
otherMethodDescription = resourceReference(
R.string.welcome_create_wallet_use_hardware_description,
),
otherMethodClick = { },
onBackClick = { },
onScanClick = { },
isScanInProgress = false,
),
),
)
@Preview(showBackground = true, widthDp = 360, heightDp = 480, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 360, heightDp = 560, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 360, heightDp = 840, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewCreateWalletStartContent(
@PreviewParameter(CreateWalletStartStateProvider::class) param: CreateWalletStartUM,
) {
TangemThemePreview {
CreateWalletStartContent(
state = param,
)
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.features.details.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@ -12,5 +11,4 @@ internal data class UserWalletListUM(
val isWalletSavingInProgress: Boolean,
val addNewWalletText: TextReference,
val onAddNewWalletClick: () -> Unit,
val addWalletBottomSheet: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty,
)

View file

@ -6,14 +6,8 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.R.*
import com.tangem.core.ui.components.bottomsheets.BottomSheetOption
import com.tangem.core.ui.components.bottomsheets.OptionsBottomSheetContent
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.features.details.entity.UserWalletListUM
import com.tangem.features.details.impl.R
@ -27,7 +21,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ -38,8 +31,6 @@ internal class UserWalletListModel @Inject constructor(
private val router: Router,
private val messageSender: UiMessageSender,
override val dispatchers: CoroutineDispatcherProvider,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val userWalletSaver: UserWalletSaver,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : Model() {
@ -58,7 +49,6 @@ internal class UserWalletListModel @Inject constructor(
isWalletSavingInProgress = false,
addNewWalletText = TextReference.EMPTY,
onAddNewWalletClick = ::onAddNewWalletClick,
addWalletBottomSheet = TangemBottomSheetConfig.Empty,
),
)
@ -90,62 +80,11 @@ internal class UserWalletListModel @Inject constructor(
private fun onAddNewWalletClick() {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
state.update { currentState ->
currentState.copy(
addWalletBottomSheet = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismissAddWalletBottomSheet,
content = createAddWalletBottomSheetContent(),
),
)
}
router.push(AppRoute.CreateWalletSelection)
} else {
withProgress(isWalletSavingInProgress) {
userWalletSaver.scanAndSaveUserWallet(modelScope)
}
}
}
private fun dismissAddWalletBottomSheet() {
state.update { currentState ->
currentState.copy(
addWalletBottomSheet = currentState.addWalletBottomSheet.copy(isShown = false),
)
}
}
private fun createAddWalletBottomSheetContent(): OptionsBottomSheetContent {
return OptionsBottomSheetContent(
options = persistentListOf(
BottomSheetOption(
key = ADD_WALLET_KEY_CREATE,
label = resourceReference(string.home_button_create_new_wallet),
),
BottomSheetOption(
key = ADD_WALLET_KEY_ADD,
label = resourceReference(string.home_button_add_existing_wallet),
),
BottomSheetOption(
key = ADD_WALLET_KEY_BUY,
label = resourceReference(string.details_buy_wallet),
),
),
onOptionClick = { optionKey ->
dismissAddWalletBottomSheet()
when (optionKey) {
ADD_WALLET_KEY_CREATE -> router.push(AppRoute.CreateWalletSelection)
ADD_WALLET_KEY_ADD -> router.push(AppRoute.AddExistingWallet)
ADD_WALLET_KEY_BUY -> modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
},
)
}
companion object {
private const val ADD_WALLET_KEY_CREATE = "create"
private const val ADD_WALLET_KEY_ADD = "add"
private const val ADD_WALLET_KEY_BUY = "buy"
}
}

View file

@ -15,13 +15,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.core.ui.R.*
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.bottomsheets.OptionsBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
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.details.component.UserWalletListComponent
@ -48,8 +44,6 @@ internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = M
onClick = state.onAddNewWalletClick,
)
}
AddWalletBottomSheet(state.addWalletBottomSheet)
}
@Composable
@ -100,15 +94,6 @@ private fun AddWalletButton(
}
}
@Composable
private fun AddWalletBottomSheet(config: TangemBottomSheetConfig) {
OptionsBottomSheet(
config = config,
title = resourceReference(string.auth_info_add_wallet_title),
containerColor = TangemTheme.colors.background.tertiary,
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)

View file

@ -86,8 +86,7 @@ internal class HomeModel @Inject constructor(
onScanClick = ::onScanClick,
onShopClick = ::onShopClick,
onSearchTokensClick = ::onSearchTokensClick,
onCreateNewWalletClick = ::onCreateNewWalletClick,
onAddExistingWalletClick = ::onAddExistingWalletClick,
onGetStartedClick = ::onGetStartedClick,
),
)
@ -145,12 +144,8 @@ internal class HomeModel @Inject constructor(
router.push(AppRoute.ManageTokens(Source.STORIES))
}
private fun onCreateNewWalletClick() {
router.push(AppRoute.CreateWalletSelection)
}
private fun onAddExistingWalletClick() {
router.push(AppRoute.AddExistingWallet)
private fun onGetStartedClick() {
router.push(AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.ColdWallet))
}
private fun scanCard() {
@ -207,13 +202,13 @@ internal class HomeModel @Inject constructor(
ifRight = {
reduxStateHolder.onUserWalletSelected(userWallet)
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse)
sendSignedInCardAnalyticsEvent(scanResponse, userWallet.isImported)
appRouter.replaceAll(AppRoute.Wallet)
},
)
}
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
@ -222,6 +217,7 @@ internal class HomeModel @Inject constructor(
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = getWalletsCount().toString(),
isImported = isImported,
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
@ -240,7 +236,7 @@ internal class HomeModel @Inject constructor(
_uiState.update { it.copy(scanInProgress = isLoading) }
}
fun handleScanError(error: TangemError) {
private fun handleScanError(error: TangemError) {
when (error) {
is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable()
is TangemSdkError -> Timber.e(error, "Scan error occurred")

View file

@ -17,9 +17,7 @@ internal fun Home(state: HomeUM, isV2StoriesEnabled: Boolean, modifier: Modifier
StoriesScreenV2(
modifier = modifier,
state = state,
onCreateNewWalletButtonClick = state.onCreateNewWalletClick,
onAddExistingWalletButtonClick = state.onAddExistingWalletClick,
onScanButtonClick = state.onScanClick,
onGetStartedClick = state.onGetStartedClick,
)
} else {
StoriesScreen(

View file

@ -29,13 +29,7 @@ import com.tangem.core.ui.R
import com.tangem.features.home.impl.ui.state.HomeUM
@Composable
internal fun StoriesScreenV2(
state: HomeUM,
onCreateNewWalletButtonClick: () -> Unit,
onAddExistingWalletButtonClick: () -> Unit,
onScanButtonClick: () -> Unit,
modifier: Modifier = Modifier,
) {
internal fun StoriesScreenV2(state: HomeUM, onGetStartedClick: () -> Unit, modifier: Modifier = Modifier) {
var currentStory by remember { mutableStateOf(state.firstStory) }
val currentStoryIndex by rememberUpdatedState(newValue = state.stepOf(currentStory))
@ -64,9 +58,7 @@ internal fun StoriesScreenV2(
isScanInProgress = state.scanInProgress,
onGoToPreviousStory = goToPreviousStory,
onGoToNextStory = goToNextStory,
onCreateNewWalletButtonClick = onCreateNewWalletButtonClick,
onAddExistingWalletButtonClick = onAddExistingWalletButtonClick,
onScanButtonClick = onScanButtonClick,
onGetStartedClick = onGetStartedClick,
),
)
}
@ -176,10 +168,7 @@ private fun StoriesScreenContentV2(config: StoriesScreenContentV2Config, modifie
) {
HomeButtonsV2(
modifier = Modifier.fillMaxWidth(),
btnScanStateInProgress = config.isScanInProgress,
onScanButtonClick = config.onScanButtonClick,
onCreateNewWalletButtonClick = config.onCreateNewWalletButtonClick,
onAddExistingWalletButtonClick = config.onAddExistingWalletButtonClick,
onGetStartedClick = config.onGetStartedClick,
)
}
}
@ -192,9 +181,7 @@ private data class StoriesScreenContentV2Config(
val isScanInProgress: Boolean,
val onGoToPreviousStory: () -> Unit = {},
val onGoToNextStory: () -> Unit = {},
val onCreateNewWalletButtonClick: () -> Unit = {},
val onAddExistingWalletButtonClick: () -> Unit = {},
val onScanButtonClick: () -> Unit = {},
val onGetStartedClick: () -> Unit = {},
)
// region Preview

View file

@ -9,116 +9,42 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.StoriesScreenTestTags
import com.tangem.core.ui.R
@Composable
internal fun HomeButtonsV2(
btnScanStateInProgress: Boolean,
onScanButtonClick: () -> Unit,
onCreateNewWalletButtonClick: () -> Unit,
onAddExistingWalletButtonClick: () -> Unit,
modifier: Modifier = Modifier,
) {
internal fun HomeButtonsV2(onGetStartedClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
CreateNewWalletButton(
modifier = Modifier
.fillMaxWidth()
.testTag(StoriesScreenTestTags.CREATE_NEW_WALLET_BUTTON),
onClick = onCreateNewWalletButtonClick,
)
AddExistingWalletButton(
modifier = Modifier
.fillMaxWidth()
.testTag(StoriesScreenTestTags.ADD_EXISTING_WALLET_BUTTON),
onClick = onAddExistingWalletButtonClick,
)
ScanCardButton(
modifier = Modifier
.fillMaxWidth()
.testTag(StoriesScreenTestTags.SCAN_BUTTON),
showProgress = btnScanStateInProgress,
onClick = onScanButtonClick,
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.common_get_started),
useDarkerColors = false,
onClick = onGetStartedClick,
)
}
}
@Composable
private fun CreateNewWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.home_button_create_new_wallet),
useDarkerColors = false,
onClick = onClick,
)
}
@Composable
private fun AddExistingWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.home_button_add_existing_wallet),
useDarkerColors = true,
onClick = onClick,
)
}
@Composable
private fun ScanCardButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
StoriesButton(
modifier = modifier,
text = stringResourceSafe(id = R.string.home_button_scan),
useDarkerColors = true,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24),
onClick = onClick,
showProgress = showProgress,
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun HomeButtonsV2Preview(@PreviewParameter(HomeButtonsV2ParameterProvider::class) state: HomeButtonsV2State) {
private fun HomeButtonsV2Preview() {
TangemThemePreview {
Box(
modifier = Modifier.background(Color.Black),
) {
HomeButtonsV2(
btnScanStateInProgress = state.btnScanStateInProgress,
onCreateNewWalletButtonClick = {},
onAddExistingWalletButtonClick = {},
onScanButtonClick = {},
onGetStartedClick = {},
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
)
}
}
}
private class HomeButtonsV2ParameterProvider : CollectionPreviewParameterProvider<HomeButtonsV2State>(
collection = listOf(
HomeButtonsV2State(
btnScanStateInProgress = false,
),
HomeButtonsV2State(
btnScanStateInProgress = true,
),
),
)
private data class HomeButtonsV2State(
val btnScanStateInProgress: Boolean,
)
// endregion Preview

View file

@ -8,8 +8,7 @@ data class HomeUM(
val onScanClick: () -> Unit,
val onShopClick: () -> Unit,
val onSearchTokensClick: () -> Unit,
val onCreateNewWalletClick: () -> Unit,
val onAddExistingWalletClick: () -> Unit,
val onGetStartedClick: () -> Unit,
) {
val firstStory: Stories get() = stories[0]

View file

@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS
@ -31,6 +32,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository,
private val userWalletsListRepository: UserWalletsListRepository,
private val canUseBiometryUseCase: CanUseBiometryUseCase,
) : Model() {
private val result = MutableStateFlow<HotWalletPasswordRequester.Result?>(null)
@ -60,7 +62,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
it.copy(
isShown = true,
accessCode = "",
useBiometricVisible = attemptRequest.hasBiometry,
useBiometricVisible = attemptRequest.isBiometryButtonVisible(),
onAccessCodeChange = ::onAccessCodeChange,
)
}
@ -83,7 +85,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
it.copy(
accessCodeColor = PinTextColor.WrongCode,
onAccessCodeChange = {},
useBiometricVisible = currentRequest.hasBiometry,
useBiometricVisible = currentRequest.isBiometryButtonVisible(),
)
}
delay(timeMillis = 500) // Delay to show the wrong access code state
@ -212,6 +214,9 @@ internal class HotAccessCodeRequestModel @Inject constructor(
dismiss()
}
private suspend fun HotWalletPasswordRequester.AttemptRequest.isBiometryButtonVisible(): Boolean =
hasBiometry && canUseBiometryUseCase()
private fun dismissState() {
uiState.update {
it.copy(isShown = false)

View file

@ -2,21 +2,13 @@ package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
interface ManageTokensComponent : ComposableContentComponent {
data class Params(
val mode: ManageTokensMode,
val source: ManageTokensSource,
) {
constructor(userWalletId: UserWalletId?, source: ManageTokensSource) : this(
source = source,
mode = userWalletId
?.let { ManageTokensMode.Wallet(userWalletId) }
?: ManageTokensMode.None,
)
}
)
interface Factory : ComponentFactory<Params, ManageTokensComponent>
}

View file

@ -17,6 +17,13 @@ sealed interface ManageTokensMode {
}
sealed interface AddCustomTokenMode {
data class Wallet(val userWalletId: UserWalletId) : AddCustomTokenMode
val userWalletId: UserWalletId
get() = when (this) {
is Account -> accountId.userWalletId
is Wallet -> userWalletId
}
data class Wallet(override val userWalletId: UserWalletId) : AddCustomTokenMode
data class Account(val accountId: AccountId) : AddCustomTokenMode
}

View file

@ -25,6 +25,8 @@ dependencies {
implementation(projects.common.ui)
/* Project - Domain */
implementation(projects.domain.account.status)
implementation(projects.domain.account)
implementation(projects.domain.card)
implementation(projects.domain.legacy)
implementation(projects.domain.manageTokens)
@ -35,6 +37,14 @@ dependencies {
implementation(projects.domain.swap.models)
implementation(projects.domain.notifications)
// region Project - Libs
implementation(projects.libs.crypto)
// endregion
// region Tangem SDKs
implementation(tangemDeps.blockchain)
// endregion
/* AndroidX */
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)

View file

@ -54,6 +54,7 @@ internal sealed class ManageTokensUM {
isSavingInProgress: Boolean = this is ManageContent && this.isSavingInProgress,
scrollToTop: StateEvent<Unit> = this.scrollToTop,
needToInteractWithColdWallet: Boolean = this is ManageContent && this.needToInteractWithColdWallet,
topBar: ManageTokensTopBarUM? = this.topBar,
): ManageTokensUM {
return when (this) {
is ManageContent -> copy(
@ -65,6 +66,7 @@ internal sealed class ManageTokensUM {
isSavingInProgress = isSavingInProgress,
scrollToTop = scrollToTop,
needToInteractWithColdWallet = needToInteractWithColdWallet,
topBar = topBar,
)
is ReadContent -> copy(
search = search,
@ -72,6 +74,7 @@ internal sealed class ManageTokensUM {
isInitialBatchLoading = isInitialBatchLoading,
isNextBatchLoading = isNextBatchLoading,
scrollToTop = scrollToTop,
topBar = topBar,
)
}
}

View file

@ -3,13 +3,22 @@ package com.tangem.features.managetokens.model
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.ui.account.toUM
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.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.managetokens.GetSupportedNetworksUseCase
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.network.Network
import com.tangem.features.managetokens.component.AddCustomTokenMode
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
@ -25,10 +34,12 @@ import com.tangem.features.managetokens.entity.item.SelectableItemUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.mapper.toCurrencyNetworkModel
import com.tangem.features.managetokens.utils.mapper.toDerivationPathModel
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
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.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -38,6 +49,8 @@ internal class CustomTokenSelectorModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val getSupportedNetworksUseCase: GetSupportedNetworksUseCase,
private val messageSender: UiMessageSender,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val accountsFeatureToggles: AccountsFeatureToggles,
paramsContainer: ParamsContainer,
) : Model() {
@ -146,9 +159,8 @@ internal class CustomTokenSelectorModel @Inject constructor(
return derivationPaths
}
private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List<Network> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e ->
private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List<Network> {
return getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e ->
val message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error))
messageSender.send(message)
@ -168,7 +180,60 @@ internal class CustomTokenSelectorModel @Inject constructor(
fun selectCustomDerivationPath(value: SelectedDerivationPath) {
when (params) {
is NetworkSelector -> return
is DerivationPathSelector -> params.onDerivationPathSelected(value)
is DerivationPathSelector -> if (accountsFeatureToggles.isFeatureEnabled) {
params.checkAccountDerivation(value)
} else {
params.onDerivationPathSelected(value)
}
}
}
private fun DerivationPathSelector.checkAccountDerivation(derivationPath: SelectedDerivationPath) =
modelScope.launch {
val accountName = derivationPath.id
?.let { Blockchain.fromId(it.rawId.value) }?.let(::AccountNodeRecognizer)
?.let { recognizer -> derivationPath.value.value?.let { recognizer.recognize(it) } }
?.let { accountNode ->
fun AccountStatus.CryptoPortfolio.sameNodeAndNotMain() = !this.account.isMainAccount &&
this.account.derivationIndex.value.toLong() == accountNode
val accounts = singleAccountStatusListSupplier(mode.userWalletId)
.first().accountStatuses
val account = accounts.find {
when (it) {
is AccountStatus.CryptoPortfolio -> it.sameNodeAndNotMain()
}
}
val accountName = when (account) {
is AccountStatus.CryptoPortfolio -> account.account.accountName.toUM()
null -> null
}
accountName
}
if (accountName == null) {
onDerivationPathSelected(derivationPath)
} else {
showAccountNameExist(
accountName = accountName.value,
onClick = { onDerivationPathSelected(derivationPath) },
)
}
}
private fun showAccountNameExist(accountName: TextReference, onClick: () -> Unit) {
val firstAction = EventMessageAction(
title = resourceReference(R.string.common_got_it),
onClick = onClick,
)
val dialogMessage = DialogMessage(
title = resourceReference(R.string.custom_token_another_account_dialog_title),
message = resourceReference(
R.string.custom_token_another_account_dialog_description,
wrappedList(accountName),
),
firstActionBuilder = { firstAction },
)
messageSender.send(dialogMessage)
}
}

View file

@ -17,6 +17,7 @@ import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensComponent
@ -40,13 +41,14 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
@ModelScoped
internal class ManageTokensModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
manageTokensListManagerFactory: ManageTokensListManager.Factory,
manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory,
paramsContainer: ParamsContainer,
@ -86,6 +88,7 @@ internal class ManageTokensModel @Inject constructor(
modelScope.launch {
manageTokensListManager.launchPagination(isCollapsed = true)
}
checkIsSupportAddCustomTokens()
}
fun reloadList() {
@ -105,16 +108,34 @@ internal class ManageTokensModel @Inject constructor(
}
}
private fun getTopBarInitialState(): ManageTokensTopBarUM = when (params.mode) {
is ManageTokensMode.Wallet -> manageContentTopBar()
is ManageTokensMode.Account -> ManageTokensTopBarUM.ReadContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = router::pop,
)
ManageTokensMode.None -> ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_search_tokens),
onBackButtonClick = router::pop,
)
}
private fun manageContentTopBar() = ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = router::pop,
endButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_plus_24,
onClicked = ::navigateToAddCustomToken,
),
)
private fun createReadContentModel(): ManageTokensUM.ReadContent {
return ManageTokensUM.ReadContent(
popBack = router::pop,
isInitialBatchLoading = true,
isNextBatchLoading = false,
items = getLoadingItems(),
topBar = ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_search_tokens),
onBackButtonClick = router::pop,
),
topBar = getTopBarInitialState(),
search = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
@ -132,14 +153,7 @@ internal class ManageTokensModel @Inject constructor(
isInitialBatchLoading = true,
isNextBatchLoading = false,
items = getLoadingItems(),
topBar = ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = router::pop,
endButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_plus_24,
onClicked = ::navigateToAddCustomToken,
),
),
topBar = getTopBarInitialState(),
search = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
@ -174,6 +188,20 @@ internal class ManageTokensModel @Inject constructor(
.launchIn(modelScope)
}
private fun checkIsSupportAddCustomTokens() {
when (val mode = params.mode) {
is ManageTokensMode.Account -> modelScope.launch {
val mainAccount = singleAccountStatusListSupplier(mode.accountId.userWalletId).first().mainAccount
if (mode.accountId == mainAccount.account.accountId) {
state.update { it.copySealed(topBar = manageContentTopBar()) }
}
}
ManageTokensMode.None,
is ManageTokensMode.Wallet,
-> Unit // use init state
}
}
private fun updateItems(items: ImmutableList<CurrencyItemUM>) {
val updatedState = state.updateAndGet { state ->
state.copySealed(

View file

@ -57,6 +57,7 @@ import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
@ -442,20 +443,23 @@ private class PreviewManageTokensComponentProvider : PreviewParameterProvider<Ma
showTangemIcon = true,
params = ManageTokensComponent.Params(
source = ManageTokensSource.ONBOARDING,
userWalletId = UserWalletId("0x"),
mode = ManageTokensMode.Wallet(UserWalletId("0x")),
),
),
PreviewManageTokensComponent(
isLoading = false,
showTangemIcon = true,
params = ManageTokensComponent.Params(source = ManageTokensSource.ONBOARDING, userWalletId = null),
params = ManageTokensComponent.Params(
source = ManageTokensSource.ONBOARDING,
mode = ManageTokensMode.None,
),
),
PreviewManageTokensComponent(
isLoading = false,
showTangemIcon = false,
params = ManageTokensComponent.Params(
source = ManageTokensSource.ONBOARDING,
userWalletId = UserWalletId("0x"),
mode = ManageTokensMode.Wallet(UserWalletId("0x")),
),
),
)

View file

@ -18,11 +18,14 @@ dependencies {
api(projects.features.sendV2.api)
api(projects.features.tokenRecieve.api)
api(projects.features.wallet.api)
api(projects.features.account.api)
/* Data */
implementation(projects.data.common)
/* Domain */
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.balanceHiding)

View file

@ -0,0 +1,55 @@
package com.tangem.features.markets.portfolio.add.api
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
internal data class AvailableToAddData(
val availableToAddWallets: Map<UserWalletId, AvailableToAddWallet>,
) {
val availableToAdd: Boolean
get() = availableToAddWallets.isNotEmpty()
val isSinglePortfolio: Boolean
get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1
}
internal data class AvailableToAddWallet(
val userWallet: UserWallet,
val accounts: Set<AccountStatus>,
val availableNetworks: Set<TokenMarketInfo.Network>,
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount>,
)
internal data class AvailableToAddAccount(
val account: AccountStatus,
val availableNetworks: Set<TokenMarketInfo.Network>,
val addedNetworks: Set<Network>,
) {
val isSingleNetwork: Boolean
get() = availableNetworks.size == 1
val availableToAddNetworks: Set<TokenMarketInfo.Network> = availableNetworks
.filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } }
.toSet()
val addedMarketNetworks: Set<TokenMarketInfo.Network> = availableNetworks
.filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } }
.toSet()
}
internal data class SelectedPortfolio(
val userWallet: UserWallet,
val account: AvailableToAddAccount,
val isAccountMode: Boolean,
val availableMorePortfolio: Boolean,
)
internal data class SelectedNetwork(
val selectedNetwork: TokenMarketInfo.Network,
val cryptoCurrency: CryptoCurrency,
val availableMoreNetwork: Boolean,
)

View file

@ -0,0 +1,58 @@
package com.tangem.features.markets.portfolio.add.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.markets.portfolio.add.api.SelectedNetwork
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import com.tangem.features.markets.portfolio.add.impl.model.AddTokenModel
import com.tangem.features.markets.portfolio.add.impl.ui.AddTokenContent
import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class AddTokenComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableContentComponent {
private val model: AddTokenModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val um = state.value ?: return
AddTokenContent(
modifier = modifier,
state = um,
)
}
data class Params(
val marketParams: TokenMarketParams,
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val selectedPortfolio: Flow<SelectedPortfolio>,
val selectedNetwork: Flow<SelectedNetwork>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onChangeNetworkClick()
fun onChangePortfolioClick()
fun onTokenAdded(status: CryptoCurrencyStatus)
}
@AssistedFactory
interface Factory : ComponentFactory<Params, AddTokenComponent> {
override fun create(context: AppComponentContext, params: Params): AddTokenComponent
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.features.markets.portfolio.add.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.portfolio.add.impl.ui.ChooseNetworkContent
import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM
import com.tangem.features.markets.portfolio.impl.model.BlockchainRowUMConverter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toPersistentList
internal class ChooseNetworkComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableContentComponent {
private val state by lazy {
val converter = BlockchainRowUMConverter(
alreadyAddedNetworks = params.alreadyAdded.mapTo(mutableSetOf()) { it.networkId },
)
val allAvailableNetworks = params.allAvailable.map { it to true }
ChooseNetworkUM(
networks = converter.convertList(allAvailableNetworks).toPersistentList(),
onNetworkClick = onNetworkClick@{ row ->
val network = params.allAvailable
.find { it.networkId == row.id }
?: return@onNetworkClick
params.callbacks.onNetworkSelected(network)
},
)
}
@Composable
override fun Content(modifier: Modifier) {
ChooseNetworkContent(state)
}
data class Params(
val alreadyAdded: Set<TokenMarketInfo.Network>,
val allAvailable: List<TokenMarketInfo.Network>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onNetworkSelected(network: TokenMarketInfo.Network)
}
@AssistedFactory
interface Factory : ComponentFactory<Params, ChooseNetworkComponent> {
override fun create(context: AppComponentContext, params: Params): ChooseNetworkComponent
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.markets.portfolio.add.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.features.markets.portfolio.add.impl.model.TokenActionsModel
import com.tangem.features.markets.portfolio.add.impl.ui.TokenActionsContent
import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class TokenActionsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : AppComponentContext by context, ComposableContentComponent {
private val model: TokenActionsModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TokenReceiveConfig.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val tokenActionsUM = state.value ?: return
TokenActionsContent(
modifier = modifier,
state = tokenActionsUM,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: TokenReceiveConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
data class Params(
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val data: Flow<PortfolioData.CryptoCurrencyData>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onLaterClick()
}
@AssistedFactory
interface Factory : ComponentFactory<Params, TokenActionsComponent> {
override fun create(context: AppComponentContext, params: Params): TokenActionsComponent
}
}

View file

@ -0,0 +1,90 @@
package com.tangem.features.markets.portfolio.add.impl.converter
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.markets.portfolio.add.api.AvailableToAddAccount
import com.tangem.features.markets.portfolio.add.api.AvailableToAddData
import com.tangem.features.markets.portfolio.add.api.AvailableToAddWallet
import javax.inject.Inject
internal class AvailableToAddDataConverter @Inject constructor(
private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
) {
suspend fun convert(
balances: Map<UserWallet, PortfolioFetcher.PortfolioBalance>,
availableNetworks: Set<TokenMarketInfo.Network>,
marketParams: TokenMarketParams,
): AvailableToAddData {
suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount {
val addedNetworks = availableNetworks
.mapNotNull { createCryptoCurrency(wallet, it, marketParams) }
.mapNotNull { getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, it) }
.mapNotNull { it.getOrNull()?.status?.currency?.network }
.toSet()
return AvailableToAddAccount(
account = this,
availableNetworks = availableNetworks,
addedNetworks = addedNetworks,
)
}
suspend fun getAvailableToAddWallet(
entry: Map.Entry<UserWallet, PortfolioFetcher.PortfolioBalance>,
): AvailableToAddWallet {
val (wallet, balance) = entry
val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks)
val accounts = balance.accountsBalance.accountStatuses
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount> = accounts
.map { it.account.accountId to it.getAvailableToAddAccount(wallet) }
.filter { (_, account) -> account.availableToAddNetworks.isNotEmpty() }
.toMap()
return AvailableToAddWallet(
userWallet = wallet,
accounts = accounts,
availableNetworks = filteredNetworks,
availableToAddAccounts = availableToAddAccounts,
)
}
val availableToAddWallets: Map<UserWalletId, AvailableToAddWallet> = balances
.map {
val (wallet, balance) = it
val availableToAddWallet = getAvailableToAddWallet(it)
wallet.walletId to availableToAddWallet
}
.filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() }
.toMap()
return AvailableToAddData(
availableToAddWallets = availableToAddWallets,
)
}
private fun UserWallet.filteredAvailableNetworks(networks: Set<TokenMarketInfo.Network>) =
filterAvailableNetworksForWalletUseCase(
userWalletId = this.walletId,
networks = networks,
)
private suspend fun createCryptoCurrency(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
marketParams: TokenMarketParams,
): CryptoCurrency? = getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = marketParams,
network = network,
)
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.features.markets.portfolio.add.api.SelectedNetwork
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent
import com.tangem.features.markets.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress
import com.tangem.features.markets.portfolio.add.impl.ui.state.AddTokenUM
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class AddTokenModel @Inject constructor(
paramsContainer: ParamsContainer,
private val uiBuilder: AddTokenUiBuilder,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
) : Model() {
private val params = paramsContainer.require<AddTokenComponent.Params>()
private val analyticsEventBuilder = params.eventBuilder
private val addTokenJob = JobHolder()
val uiState: StateFlow<AddTokenUM?>
field = MutableStateFlow(value = null)
init {
combine(
flow = params.selectedNetwork.distinctUntilChanged(),
flow2 = params.selectedPortfolio.distinctUntilChanged(),
transform = { selectedNetwork, selectedPortfolio ->
addTokenJob.cancel()
val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio)
uiBuilder.updateContent(
selectedPortfolio = selectedPortfolio,
selectedNetwork = selectedNetwork,
isTangemIconVisible = isTangemIconVisible,
onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) },
)
},
)
.onEach { newUI -> uiState.value = newUI }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun onAddClick(selectedNetwork: SelectedNetwork, selectedPortfolio: SelectedPortfolio) =
modelScope.launch(dispatchers.default) {
val um = uiState.value ?: return@launch
uiState.value = um.toggleProgress(true)
val blockchainNames = listOf(selectedNetwork.selectedNetwork)
.mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name }
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames))
val cryptoCurrency = selectedNetwork.cryptoCurrency
val accountId = selectedPortfolio.account.account.account.accountId
saveCryptoCurrenciesUseCase(
accountId = accountId,
add = listOf(cryptoCurrency),
remove = listOf(),
)
val status = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
).getOrNull() ?: return@launch
params.callbacks.onTokenAdded(status.status)
uiState.value = um.toggleProgress(false)
}
private suspend fun needColdWalletInteraction(
selectedNetwork: SelectedNetwork,
selectedPortfolio: SelectedPortfolio,
): Boolean = coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = selectedPortfolio.userWallet.walletId,
networksWithDerivationPath = mapOf(selectedNetwork.selectedNetwork.networkId to null),
)
}

View file

@ -0,0 +1,103 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.tangem.common.ui.account.CryptoPortfolioIconUM
import com.tangem.common.ui.account.toUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.iconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.AccountStatus
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.api.SelectedNetwork
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent
import com.tangem.features.markets.portfolio.add.impl.ui.state.AddTokenUM
import javax.inject.Inject
@ModelScoped
internal class AddTokenUiBuilder @Inject constructor(
paramsContainer: ParamsContainer,
) {
private val params = paramsContainer.require<AddTokenComponent.Params>()
private fun createNetwork(selectedNetwork: SelectedNetwork): AddTokenUM.Network {
return AddTokenUM.Network(
icon = selectedNetwork.cryptoCurrency.network.iconResId,
name = stringReference(selectedNetwork.cryptoCurrency.network.name),
editable = selectedNetwork.availableMoreNetwork,
onClick = { params.callbacks.onChangeNetworkClick() },
)
}
private fun createPortfolio(selectedPortfolio: SelectedPortfolio): AddTokenUM.Portfolio {
val accountIcon: CryptoPortfolioIconUM?
val portfolioName: TextReference
when (selectedPortfolio.isAccountMode) {
false -> {
accountIcon = null
portfolioName = stringReference(selectedPortfolio.userWallet.name)
}
true -> {
val accountStatus = selectedPortfolio.account.account
portfolioName = accountStatus.account.accountName.toUM().value
accountIcon = when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.account.icon.toUM()
}
}
}
return AddTokenUM.Portfolio(
accountIconUM = accountIcon,
name = portfolioName,
editable = selectedPortfolio.availableMorePortfolio,
onClick = { params.callbacks.onChangePortfolioClick() },
)
}
fun updateContent(
selectedPortfolio: SelectedPortfolio,
selectedNetwork: SelectedNetwork,
isTangemIconVisible: Boolean,
onConfirmClick: () -> Unit,
): AddTokenUM {
// its may happens when change portfolio after selected both params in line navigation
val isAvailableNetwork = selectedPortfolio.account.availableToAddNetworks
.any { selectedNetwork.selectedNetwork.networkId == it.networkId }
val button = AddTokenUM.Button(
isEnabled = isAvailableNetwork,
showProgress = false,
isTangemIconVisible = isTangemIconVisible,
text = resourceReference(R.string.common_add),
onConfirmClick = onConfirmClick,
)
val networkUM = createNetwork(selectedNetwork)
val portfolioUM = createPortfolio(selectedPortfolio)
val currency = selectedNetwork.cryptoCurrency
val tokenToAdd = TokenItemState.Content(
id = currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(currency),
titleState = TokenItemState.TitleState.Content(stringReference(currency.name)),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""),
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return AddTokenUM(
tokenToAdd = tokenToAdd,
network = networkUM,
portfolio = portfolioUM,
button = button,
)
}
companion object {
fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy(
button = this.button.copy(showProgress = showProgress),
)
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.markets.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.markets.portfolio.impl.model.TokenActionsHandler
import com.tangem.features.markets.portfolio.impl.model.TokenActionsHandler.HandledQuickAction
import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class TokenActionsModel @Inject constructor(
paramsContainer: ParamsContainer,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
tokenActionsIntentsFactory: TokenActionsHandler.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val uiBuilder: TokenActionsUiBuilder,
private val analyticsEventHandler: AnalyticsEventHandler,
private val receiveAddressesFactory: ReceiveAddressesFactory,
) : Model() {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
private val analyticsEventBuilder get() = params.eventBuilder
private val currentAppCurrency = getSelectedAppCurrencyUseCase.invokeOrDefault()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val tokenActionsHandler: TokenActionsHandler =
tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
updateTokenReceiveBSConfig = { },
onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) },
)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
val uiState: StateFlow<TokenActionsUM?> = params.data
.mapLatest { uiBuilder.build(it, tokenActionsHandler) }
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
private fun handledQuickAction(handledAction: HandledQuickAction) {
val event = analyticsEventBuilder.quickActionClick(
actionUM = handledAction.action,
blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name,
)
analyticsEventHandler.send(event)
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (!isReceive) return
modelScope.launch {
val tokenConfig = receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
) ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
}
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.markets.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.model.PortfolioTokenUMConverter
import com.tangem.features.markets.portfolio.impl.model.TokenActionsHandler
import javax.inject.Inject
@ModelScoped
internal class TokenActionsUiBuilder @Inject constructor(
paramsContainer: ParamsContainer,
) {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM {
val status = data.status
val tokenUM = TokenItemState.Content(
id = status.currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(status.currency),
titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)),
fiatAmountState = null,
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return TokenActionsUM(
token = tokenUM,
onLaterClick = { params.callbacks.onLaterClick() },
quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler),
)
}
}

View file

@ -0,0 +1,281 @@
package com.tangem.features.markets.portfolio.add.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
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.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.TokenItem
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.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags
import com.tangem.domain.models.account.AccountName
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.impl.ui.state.AddTokenUM
import java.util.UUID
@Composable
internal fun AddTokenContent(state: AddTokenUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
) {
TokenItem(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action),
state = state.tokenToAdd,
isBalanceHidden = false,
)
SpacerH(TangemTheme.dimens.spacing14)
Column(
modifier = Modifier.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(TangemTheme.dimens.radius14),
),
) {
PortfolioRow(state.portfolio)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp),
thickness = TangemTheme.dimens.size0_5,
color = TangemTheme.colors.stroke.primary,
)
NetworkRow(state.network)
}
SpacerH16()
AddButton(
modifier = modifier.fillMaxWidth(),
state = state.button,
)
}
}
@Composable
private fun PortfolioRow(state: AddTokenUM.Portfolio, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clickable(enabled = state.editable, onClick = state.onClick)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val leftText = if (state.isAccountMode) R.string.account_details_title else R.string.wc_common_wallet
Text(
modifier = Modifier.weight(1f),
text = stringResourceSafe(leftText),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
SpacerW12()
if (state.accountIconUM != null) {
AccountIcon(
name = state.name,
icon = state.accountIconUM,
size = AccountIconSize.Small,
)
}
Text(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 4.dp),
text = state.name.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
if (state.editable) {
Icon(
modifier = Modifier
.size(width = 18.dp, height = 24.dp)
.testTag(WalletConnectBottomSheetTestTags.NETWORKS_SELECTOR_ICON),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
}
@Composable
private fun NetworkRow(state: AddTokenUM.Network, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clickable(enabled = state.editable, onClick = state.onClick)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = stringResourceSafe(R.string.wc_common_networks),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
SpacerW12()
Icon(
modifier = Modifier.size(24.dp),
tint = Color.Unspecified,
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
)
Text(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 4.dp),
text = state.name.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
if (state.editable) {
Icon(
modifier = Modifier
.size(width = 18.dp, height = 24.dp)
.testTag(WalletConnectBottomSheetTestTags.NETWORKS_SELECTOR_ICON),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
}
@Composable
private fun AddButton(state: AddTokenUM.Button, modifier: Modifier = Modifier) {
val endIcon = if (state.isEnabled && state.isTangemIconVisible) {
TangemButtonIconPosition.End(R.drawable.ic_tangem_24)
} else {
TangemButtonIconPosition.None
}
PrimaryButtonIconEnd(
modifier = modifier,
text = state.text.resolveReference(),
iconResId = endIcon.iconResId,
onClick = state.onConfirmClick,
enabled = state.isEnabled,
showProgress = state.showProgress,
)
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(PreviewProvider::class) state: AddTokenUM) {
TangemThemePreview {
AddTokenContent(
state = state,
)
}
}
private class PreviewProvider : PreviewParameterProvider<AddTokenUM> {
private val tokenState
get() = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(
text = stringReference(value = "Tether"),
),
fiatAmountState = FiatAmountState.Content(text = ""),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""),
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")),
onItemClick = {},
onItemLongClick = {},
)
val networkUM
get() = AddTokenUM.Network(
icon = R.drawable.img_eth_22,
name = stringReference("Ethereum"),
editable = true,
onClick = {},
)
val button
get() = AddTokenUM.Button(
isEnabled = false,
showProgress = false,
isTangemIconVisible = false,
text = resourceReference(R.string.common_add),
onConfirmClick = { },
)
val account
get() = AddTokenUM.Portfolio(
accountIconUM = AccountIconPreviewData.randomAccountIcon(),
name = AccountName.DefaultMain.toUM().value,
editable = true,
onClick = {},
)
val wallet
get() = AddTokenUM.Portfolio(
accountIconUM = null,
name = stringReference("Wallet"),
editable = true,
onClick = {},
)
override val values: Sequence<AddTokenUM>
get() = sequenceOf(
AddTokenUM(
tokenToAdd = tokenState,
network = networkUM,
portfolio = account,
button = button,
),
AddTokenUM(
tokenToAdd = tokenState,
network = networkUM,
portfolio = wallet,
button = button,
),
AddTokenUM(
tokenToAdd = tokenState,
network = networkUM.copy(editable = false),
portfolio = wallet.copy(editable = false),
button = button.copy(
isEnabled = true,
isTangemIconVisible = true,
),
),
)
}

View file

@ -0,0 +1,110 @@
package com.tangem.features.markets.portfolio.add.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
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.util.fastForEachIndexed
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.rows.BlockchainRow
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
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.markets.impl.R
import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
private const val DISABLED_ALPHA = 0.4f
@Composable
internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.action),
) {
state.networks.fastForEachIndexed { index, model ->
key(model.id) {
BlockchainRow(
model = model,
itemPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing14,
),
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }),
) {
if (!model.isEnabled) {
Label(
modifier = Modifier.alpha(DISABLED_ALPHA),
state = LabelUM(
text = resourceReference(R.string.common_added),
style = LabelStyle.REGULAR,
),
)
}
}
}
}
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) {
TangemThemePreview {
ChooseNetworkContent(
state = content,
)
}
}
internal class ChooseNetworkContentProvider : PreviewParameterProvider<ChooseNetworkUM> {
private val blockchainRow = BlockchainRowUM(
id = UUID.randomUUID().toString(),
name = "Etherium 3",
type = "TEST",
iconResId = R.drawable.img_eth_22,
isMainNetwork = false,
isSelected = true,
isEnabled = true,
)
override val values: Sequence<ChooseNetworkUM>
get() = sequenceOf(
ChooseNetworkUM(
onNetworkClick = {},
networks = persistentListOf(
blockchainRow.copy(
type = "MAIN",
isMainNetwork = true,
),
blockchainRow.copy(
iconResId = R.drawable.ic_bsc_16,
isEnabled = false,
),
blockchainRow.copy(iconResId = R.drawable.img_polygon_22),
blockchainRow.copy(iconResId = R.drawable.img_optimism_22),
),
),
)
}

View file

@ -0,0 +1,204 @@
package com.tangem.features.markets.portfolio.add.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.badge.drawBadge
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
@Composable
internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
) {
TokenItem(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action),
state = state.token,
isBalanceHidden = false,
)
SpacerH(TangemTheme.dimens.spacing14)
Column(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.action),
) {
state.quickActions.actions.fastForEach {
key(it.title) {
ActionRow(
state = it,
onClick = { state.quickActions.onQuickActionClick(it) },
onLongClick = { state.quickActions.onQuickActionLongClick(it) },
)
}
}
}
SpacerH16()
SecondaryButton(
modifier = modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_later),
onClick = state.onLaterClick,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ActionRow(
state: QuickActionUM,
onClick: () -> Unit,
onLongClick: (() -> Unit),
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
val onLongClickInternal = {
hapticManager.perform(TangemHapticEffect.View.LongPress)
onLongClick()
}
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onLongClick = onLongClickInternal.takeIf { state.longClickAvailable },
onClick = {
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
onClick()
},
)
.padding(horizontal = TangemTheme.dimens.spacing12, vertical = TangemTheme.dimens.spacing15),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
val containerColor = TangemTheme.colors.background.action
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = CircleShape,
)
.size(36.dp)
.drawWithContent {
drawContent()
if (state is QuickActionUM.Exchange && state.showBadge) {
drawBadge(containerColor = containerColor, offset = 4.dp)
}
},
) {
Icon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size16),
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = state.description.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(TokenActionsContentPreviewProvider::class) state: TokenActionsUM) {
TangemThemePreview {
TokenActionsContent(
state = state,
)
}
}
private class TokenActionsContentPreviewProvider : PreviewParameterProvider<TokenActionsUM> {
private val tokenState
get() = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(
text = stringReference(value = "Tether"),
),
fiatAmountState = null,
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")),
onItemClick = {},
onItemLongClick = {},
)
override val values: Sequence<TokenActionsUM>
get() = sequenceOf(
TokenActionsUM(
quickActions = PortfolioTokenUM.QuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(showBadge = true),
QuickActionUM.Receive,
),
onQuickActionClick = {},
onQuickActionLongClick = {},
),
token = tokenState,
onLaterClick = {},
),
)
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.markets.portfolio.add.impl.ui.state
import androidx.annotation.DrawableRes
import com.tangem.common.ui.account.CryptoPortfolioIconUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
data class AddTokenUM(
val tokenToAdd: TokenItemState,
val network: Network,
val portfolio: Portfolio,
val button: Button,
) {
data class Portfolio(
val accountIconUM: CryptoPortfolioIconUM?,
val name: TextReference,
val editable: Boolean,
val onClick: () -> Unit,
) {
val isAccountMode get() = accountIconUM != null
}
data class Network(
@DrawableRes val icon: Int,
val name: TextReference,
val editable: Boolean,
val onClick: () -> Unit,
)
data class Button(
val isEnabled: Boolean,
val showProgress: Boolean,
val isTangemIconVisible: Boolean,
val onConfirmClick: () -> Unit,
val text: TextReference,
)
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.markets.portfolio.add.impl.ui.state
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import kotlinx.collections.immutable.ImmutableList
data class ChooseNetworkUM(
val networks: ImmutableList<BlockchainRowUM>,
val onNetworkClick: (BlockchainRowUM) -> Unit,
)

View file

@ -0,0 +1,10 @@
package com.tangem.features.markets.portfolio.add.impl.ui.state
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM
internal data class TokenActionsUM(
val token: TokenItemState,
val quickActions: PortfolioTokenUM.QuickActions,
val onLaterClick: () -> Unit,
)

View file

@ -2,9 +2,9 @@ package com.tangem.features.markets.portfolio.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -22,16 +22,13 @@ import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.markets.SaveMarketTokensUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase
import com.tangem.domain.transaction.usecase.GetEnsNameUseCase
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.features.markets.impl.R
@ -70,9 +67,8 @@ internal class MarketsPortfolioModel @Inject constructor(
private val addToPortfolioManager: AddToPortfolioManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle,
private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase,
private val getEnsNameUseCase: GetEnsNameUseCase,
private val userWalletImageFetcher: UserWalletImageFetcher,
private val receiveAddressesFactory: ReceiveAddressesFactory,
) : Model() {
val state: StateFlow<MyPortfolioUM> get() = _state
@ -374,50 +370,16 @@ internal class MarketsPortfolioModel @Inject constructor(
}
private fun configureReceiveAddresses(quickAction: TokenActionsHandler.HandledQuickAction) {
when (quickAction.action) {
TokenActionsBSContentUM.Action.Receive -> {
val addresses = quickAction.cryptoCurrencyData.status.value.networkAddress ?: return
val cryptoCurrency = quickAction.cryptoCurrencyData.status.currency
modelScope.launch {
val ensName = getEnsNameUseCase.invoke(
userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId,
network = cryptoCurrency.network,
address = addresses.defaultAddress.value,
)
val receiveAddresses = buildList {
ensName?.let { ens ->
add(
ReceiveAddressModel(
nameService = ReceiveAddressModel.NameService.Ens,
value = ens,
),
)
}
addresses.availableAddresses.map { address ->
add(
ReceiveAddressModel(
nameService = when (address.type) {
NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default
NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy
},
value = address.value,
),
)
}
}
val tokenConfig = TokenReceiveConfig(
shouldShowWarning = cryptoCurrency.name !in getViewedTokenReceiveWarningUseCase(),
cryptoCurrency = cryptoCurrency,
userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId,
showMemoDisclaimer = cryptoCurrency.network.transactionExtrasType != Network
.TransactionExtrasType.NONE,
receiveAddress = receiveAddresses,
)
bottomSheetNavigation.activate(tokenConfig)
}
val isNewReceive = quickAction.action == TokenActionsBSContentUM.Action.Receive &&
tokenReceiveFeatureToggle.isNewTokenReceiveEnabled
if (isNewReceive) {
modelScope.launch {
val tokenConfig = receiveAddressesFactory.create(
status = quickAction.cryptoCurrencyData.status,
userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId,
) ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
}
else -> Unit
}
}
}

View file

@ -42,55 +42,60 @@ internal class PortfolioTokenUMConverter(
walletId = value.userWallet.walletId,
isBalanceHidden = isBalanceHidden,
isQuickActionsShown = false,
quickActions = quickActions(cryptoData = value),
quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler),
)
}
private fun quickActions(cryptoData: PortfolioData.CryptoCurrencyData): PortfolioTokenUM.QuickActions {
return PortfolioTokenUM.QuickActions(
actions = toQuickActions(cryptoData.actions),
onQuickActionClick = {
when (it) {
QuickActionUM.Buy -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Buy,
cryptoCurrencyData = cryptoData,
)
is QuickActionUM.Exchange -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Exchange,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Receive -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Receive,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Stake -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Stake,
cryptoCurrencyData = cryptoData,
)
}
},
onQuickActionLongClick = {
if (it == QuickActionUM.Receive) {
tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.CopyAddress,
cryptoCurrencyData = cryptoData,
)
}
},
)
}
private fun toQuickActions(actions: List<TokenActionsState.ActionState>) = buildList {
actions.forEach { action ->
if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) {
when (action) {
is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy
is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(showBadge = action.showBadge)
is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive
is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake
else -> null
}?.let(::add)
}
companion object {
fun quickActions(
cryptoData: PortfolioData.CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
): PortfolioTokenUM.QuickActions {
return PortfolioTokenUM.QuickActions(
actions = toQuickActions(cryptoData.actions),
onQuickActionClick = {
when (it) {
QuickActionUM.Buy -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Buy,
cryptoCurrencyData = cryptoData,
)
is QuickActionUM.Exchange -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Exchange,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Receive -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Receive,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Stake -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Stake,
cryptoCurrencyData = cryptoData,
)
}
},
onQuickActionLongClick = {
if (it == QuickActionUM.Receive) {
tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.CopyAddress,
cryptoCurrencyData = cryptoData,
)
}
},
)
}
}.toImmutableList()
private fun toQuickActions(actions: List<TokenActionsState.ActionState>) = buildList {
actions.forEach { action ->
if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) {
when (action) {
is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy
is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(showBadge = action.showBadge)
is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive
is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake
else -> null
}?.let(::add)
}
}
}.toImmutableList()
}
}

View file

@ -14,6 +14,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.nft.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.account)
/* Project - Core */
implementation(projects.core.decompose)

View file

@ -3,6 +3,7 @@ package com.tangem.features.nft.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.nft.models.NFTAsset
@ -12,7 +13,9 @@ interface NFTDetailsBlockComponent : ComposableContentComponent {
val userWalletId: UserWalletId,
val nftAsset: NFTAsset,
val nftCollectionName: String,
val title: TextReference,
val account: Account.CryptoPortfolio?,
val isAccountsMode: Boolean,
val walletTitle: TextReference,
val isSuccessScreen: Boolean,
)

View file

@ -2,11 +2,15 @@ package com.tangem.features.nft.details.block
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.toUM
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.nft.component.NFTDetailsBlockComponent
import com.tangem.features.nft.details.block.ui.NFTDetailsBlock
import com.tangem.features.nft.impl.R
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -16,13 +20,23 @@ class DefaultNFTDetailsBlockComponent @AssistedInject constructor(
@Assisted private val params: NFTDetailsBlockComponent.Params,
) : NFTDetailsBlockComponent, AppComponentContext by context {
private val account = params.account
@Composable
override fun Content(modifier: Modifier) {
NFTDetailsBlock(
assetName = stringReference(params.nftAsset.name.orEmpty()),
collectionName = stringReference(params.nftCollectionName),
assetImage = params.nftAsset.media?.imageUrl,
title = params.title,
accountTitleUM = if (account != null && params.isAccountsMode) {
AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = account.accountName.toUM().value,
icon = account.icon.toUM(),
)
} else {
AccountTitleUM.Text(params.walletTitle)
},
isSuccessScreen = params.isSuccessScreen,
networkIconRes = getActiveIconRes(params.nftAsset.network.rawId),
)

View file

@ -11,6 +11,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@ -23,7 +25,7 @@ import com.tangem.features.nft.impl.R
@Suppress("LongParameterList")
@Composable
internal fun NFTDetailsBlock(
title: TextReference,
accountTitleUM: AccountTitleUM,
assetName: TextReference,
collectionName: TextReference,
assetImage: String?,
@ -38,11 +40,7 @@ internal fun NFTDetailsBlock(
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
AccountTitle(accountTitleUM)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@ -91,7 +89,7 @@ private fun NFTDetailsBlock_Preview() {
collectionName = stringReference("NFT Collection"),
assetImage = null,
networkIconRes = R.drawable.img_polygon_22,
title = stringReference("From My Wallet"),
accountTitleUM = AccountTitleUM.Text(stringReference("From My Wallet")),
isSuccessScreen = false,
)
}

View file

@ -14,10 +14,7 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.models.Asset
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.TokenReceiveNotification
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.network.NetworkStatus
@ -26,8 +23,7 @@ import com.tangem.domain.nft.GetNFTCurrencyUseCase
import com.tangem.domain.nft.GetNFTNetworkStatusUseCase
import com.tangem.domain.nft.GetNFTNetworksUseCase
import com.tangem.domain.nft.analytics.NFTAnalyticsEvent
import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase
import com.tangem.domain.transaction.usecase.GetEnsNameUseCase
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.nft.impl.R
import com.tangem.features.nft.receive.NFTReceiveComponent
import com.tangem.features.nft.receive.entity.NFTReceiveUM
@ -36,7 +32,6 @@ import com.tangem.features.nft.receive.entity.transformer.ToggleSearchBarTransfo
import com.tangem.features.nft.receive.entity.transformer.UpdateDataStateTransformer
import com.tangem.features.nft.receive.entity.transformer.UpdateSearchQueryTransformer
import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.*
@ -56,9 +51,8 @@ internal class NFTReceiveModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val messageSender: UiMessageSender,
private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle,
private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase,
private val getEnsNameUseCase: GetEnsNameUseCase,
private val getNFTCurrencyUseCase: GetNFTCurrencyUseCase,
private val receiveAddressesFactory: ReceiveAddressesFactory,
paramsContainer: ParamsContainer,
) : Model() {
@ -196,54 +190,11 @@ internal class NFTReceiveModel @Inject constructor(
private suspend fun configureReceiveAddresses(addresses: NetworkAddress, network: Network): TokenReceiveConfig {
val cryptoCurrency = getNFTCurrencyUseCase.invoke(network)
val ensName = getEnsNameUseCase.invoke(
return receiveAddressesFactory.createForNft(
userWalletId = params.userWalletId,
addresses = addresses,
network = network,
address = addresses.defaultAddress.value,
)
val receiveAddresses = buildList {
ensName?.let { ens ->
add(
ReceiveAddressModel(
nameService = ReceiveAddressModel.NameService.Ens,
value = ens,
),
)
}
addresses.availableAddresses.map { address ->
add(
ReceiveAddressModel(
nameService = when (address.type) {
NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default
NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy
},
value = address.value,
),
)
}
}
val notifications = buildList {
if (BlockchainUtils.isSolana(network.rawId)) {
add(
TokenReceiveNotification(
title = R.string.nft_receive_unsupported_types,
subtitle = R.string.nft_receive_unsupported_types_description,
),
)
}
}
return TokenReceiveConfig(
shouldShowWarning = Asset.NFT.name !in getViewedTokenReceiveWarningUseCase(),
cryptoCurrency = cryptoCurrency,
userWalletId = params.userWalletId,
showMemoDisclaimer = false,
receiveAddress = receiveAddresses,
tokenReceiveNotification = notifications,
asset = Asset.NFT,
nft = cryptoCurrency,
)
}
}

View file

@ -14,11 +14,11 @@ import com.tangem.core.error.UniversalError
import com.tangem.core.error.ext.universalError
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent
import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.ui.state.OnboardingVisaAccessCodeUM
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent
@ -57,11 +57,12 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
),
)
private val activationInput = when (val status = params.scanResponse.visaCardActivationStatus) {
is VisaCardActivationStatus.NotStartedActivation -> status.activationInput
is VisaCardActivationStatus.ActivationStarted -> status.activationInput
else -> error("Visa activation status is not set or incorrect for this step")
}
private val activationInput: VisaActivationInput = TODO("Fix visaCardActivationStatus retrieval")
// when (val status = params.scanResponse.visaCardActivationStatus) {
// is VisaCardActivationStatus.NotStartedActivation -> status.activationInput
// is VisaCardActivationStatus.ActivationStarted -> status.activationInput
// else -> error("Visa activation status is not set or incorrect for this step")
// }
private val _uiState = MutableStateFlow(getInitialState())

View file

@ -12,15 +12,15 @@ import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.datasource.local.visa.VisaOTPStorage
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Config
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Params
@ -180,13 +180,15 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
onDone.emit(Params.DoneEvent.Activated)
}
@Suppress("UnusedPrivateProperty")
private suspend fun createUserWallet(scanResponse: ScanResponse, authTokens: VisaAuthTokens): UserWallet =
withContext(dispatchers.io) {
val newActivationStatus = VisaCardActivationStatus.Activated(visaAuthTokens = authTokens)
requireNotNull(
value = coldUserWalletBuilderFactory.create(
scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus),
// scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus),
scanResponse = scanResponse,
).build(),
lazyMessage = { "User wallet not created" },
)

View file

@ -10,10 +10,6 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.card.common.visa.VisaWalletPublicKeyUtility
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardWalletDataToSignRequest
import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
@ -143,74 +139,75 @@ internal class OnboardingVisaModel @Inject constructor(
}
private fun initializeRoute(): OnboardingVisaRoute {
return when (val activationStatus = params.scanResponse.visaCardActivationStatus) {
is VisaCardActivationStatus.ActivationStarted -> {
when (val remoteState = activationStatus.remoteState) {
is VisaActivationRemoteState.CardWalletSignatureRequired -> {
if (activationStatus.activationInput.isAccessCodeSet) {
OnboardingVisaRoute.WelcomeBack(
activationInput = activationStatus.activationInput,
dataToSignByCardWalletRequest = VisaCardWalletDataToSignRequest(
activationOrderInfo = remoteState.activationOrderInfo,
cardWalletAddress = activationStatus.cardWalletAddress,
),
)
} else {
OnboardingVisaRoute.AccessCode
}
}
is VisaActivationRemoteState.CustomerWalletSignatureRequired -> {
remoteState.getRoute(activationStatus)
}
VisaActivationRemoteState.PaymentAccountDeploying -> {
OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.Approve)
}
VisaActivationRemoteState.WaitingForActivationFinishing -> {
OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode)
}
is VisaActivationRemoteState.AwaitingPinCode -> {
OnboardingVisaRoute.PinCode(
activationOrderInfo = remoteState.activationOrderInfo,
pinCodeValidationError = false,
)
}
VisaActivationRemoteState.Activated,
VisaActivationRemoteState.BlockedForActivation,
VisaActivationRemoteState.Failed,
-> error("Activation status is not correct for onboarding flow")
}
}
is VisaCardActivationStatus.NotStartedActivation -> OnboardingVisaRoute.Welcome
else -> error("Visa activation status is not correct for onboarding flow")
}
TODO("Fix visaCardActivationStatus retrieval")
// return when (val activationStatus = params.scanResponse.visaCardActivationStatus) {
// is VisaCardActivationStatus.ActivationStarted -> {
// when (val remoteState = activationStatus.remoteState) {
// is VisaActivationRemoteState.CardWalletSignatureRequired -> {
// if (activationStatus.activationInput.isAccessCodeSet) {
// OnboardingVisaRoute.WelcomeBack(
// activationInput = activationStatus.activationInput,
// dataToSignByCardWalletRequest = VisaCardWalletDataToSignRequest(
// activationOrderInfo = remoteState.activationOrderInfo,
// cardWalletAddress = activationStatus.cardWalletAddress,
// ),
// )
// } else {
// OnboardingVisaRoute.AccessCode
// }
// }
// is VisaActivationRemoteState.CustomerWalletSignatureRequired -> {
// remoteState.getRoute(activationStatus)
// }
// VisaActivationRemoteState.PaymentAccountDeploying -> {
// OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.Approve)
// }
// VisaActivationRemoteState.WaitingForActivationFinishing -> {
// OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode)
// }
// is VisaActivationRemoteState.AwaitingPinCode -> {
// OnboardingVisaRoute.PinCode(
// activationOrderInfo = remoteState.activationOrderInfo,
// pinCodeValidationError = false,
// )
// }
// VisaActivationRemoteState.Activated,
// VisaActivationRemoteState.BlockedForActivation,
// VisaActivationRemoteState.Failed,
// -> error("Activation status is not correct for onboarding flow")
// }
// }
// is VisaCardActivationStatus.NotStartedActivation -> OnboardingVisaRoute.Welcome
// else -> error("Visa activation status is not correct for onboarding flow")
// }
}
private fun VisaActivationRemoteState.CustomerWalletSignatureRequired.getRoute(
activationStatus: VisaCardActivationStatus.ActivationStarted,
): OnboardingVisaRoute {
val foundWalletCardId = tryToFindExistingWalletCardId(this.activationOrderInfo.customerWalletAddress)
val request = VisaCustomerWalletDataToSignRequest(
orderId = this.activationOrderInfo.orderId,
cardWalletAddress = activationStatus.cardWalletAddress,
customerWalletAddress = this.activationOrderInfo.customerWalletAddress,
)
val preparationDataForApprove = PreparationDataForApprove(
customerWalletAddress = this.activationOrderInfo.customerWalletAddress,
request = request,
)
return if (foundWalletCardId != null) {
OnboardingVisaRoute.TangemWalletApproveOption(
preparationDataForApprove = preparationDataForApprove,
foundWalletCardId = foundWalletCardId,
allowNavigateBack = false,
)
} else {
OnboardingVisaRoute.ChooseWallet(
preparationDataForApprove = preparationDataForApprove,
)
}
}
// private fun VisaActivationRemoteState.CustomerWalletSignatureRequired.getRoute(
// activationStatus: VisaCardActivationStatus.ActivationStarted,
// ): OnboardingVisaRoute {
// val foundWalletCardId = tryToFindExistingWalletCardId(this.activationOrderInfo.customerWalletAddress)
// val request = VisaCustomerWalletDataToSignRequest(
// orderId = this.activationOrderInfo.orderId,
// cardWalletAddress = activationStatus.cardWalletAddress,
// customerWalletAddress = this.activationOrderInfo.customerWalletAddress,
// )
// val preparationDataForApprove = PreparationDataForApprove(
// customerWalletAddress = this.activationOrderInfo.customerWalletAddress,
// request = request,
// )
//
// return if (foundWalletCardId != null) {
// OnboardingVisaRoute.TangemWalletApproveOption(
// preparationDataForApprove = preparationDataForApprove,
// foundWalletCardId = foundWalletCardId,
// allowNavigateBack = false,
// )
// } else {
// OnboardingVisaRoute.ChooseWallet(
// preparationDataForApprove = preparationDataForApprove,
// )
// }
// }
private fun tryToFindExistingWalletCardId(targetAddress: String): String? {
val wallets = getWalletsUseCase.invokeSync().filter { it.isLocked.not() }

View file

@ -46,6 +46,7 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(projects.domain.settings)
implementation(projects.domain.transaction.models)
implementation(projects.domain.account.status)
/** DI */
implementation(deps.hilt.android)

View file

@ -1,6 +1,7 @@
package com.tangem.features.onramp.selecttoken
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
@ -41,11 +42,13 @@ internal class DefaultOnrampOperationComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state = model.state.collectAsStateWithLifecycle()
val state by model.state.collectAsStateWithLifecycle()
val onrampTokenListState by onrampTokenListComponent.uiState.collectAsStateWithLifecycle()
OnrampSelectToken(
state = state.value,
state = state,
onrampTokenListComponent = onrampTokenListComponent,
onrampTokenListState = onrampTokenListState,
hotCryptoComponent = hotCryptoComponent,
modifier = modifier,
)

View file

@ -21,12 +21,14 @@ import com.tangem.features.onramp.hottokens.HotCryptoComponent
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.selecttoken.entity.OnrampOperationUM
import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun OnrampSelectToken(
state: OnrampOperationUM,
onrampTokenListComponent: OnrampTokenListComponent,
onrampTokenListState: TokenListUM,
hotCryptoComponent: HotCryptoComponent?,
modifier: Modifier = Modifier,
) {
@ -50,8 +52,9 @@ internal fun OnrampSelectToken(
)
}
item(key = "token_list", contentType = "token_list") {
onrampTokenListComponent.Content(
with(onrampTokenListComponent) {
content(
uiState = onrampTokenListState,
modifier = Modifier
.padding(top = 8.dp)
.padding(horizontal = 16.dp)

View file

@ -2,6 +2,7 @@ package com.tangem.features.onramp.swap
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -54,12 +55,16 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state = model.state.collectAsStateWithLifecycle()
val state by model.state.collectAsStateWithLifecycle()
val fromTokensState by selectFromTokenListComponent.uiState.collectAsStateWithLifecycle()
val toTokensState by selectToTokenListComponent.uiState.collectAsStateWithLifecycle()
SwapSelectTokens(
state = state.value,
state = state,
selectFromTokenListComponent = selectFromTokenListComponent,
selectFromTokenListState = fromTokensState,
selectToTokenListComponent = selectToTokenListComponent,
selectToTokenListState = toTokensState,
modifier = modifier,
)
}

View file

@ -3,14 +3,15 @@ package com.tangem.features.onramp.swap.availablepairs
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableListContentComponent
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import kotlinx.coroutines.flow.StateFlow
/** Token list component that present list of available tokens for swap */
@Stable
internal interface AvailableSwapPairsComponent : ComposableContentComponent {
internal interface AvailableSwapPairsComponent : ComposableListContentComponent<TokenListUM> {
/** Component factory */
interface Factory : ComponentFactory<Params, AvailableSwapPairsComponent>

View file

@ -1,17 +1,17 @@
package com.tangem.features.onramp.swap.availablepairs
import androidx.compose.runtime.Composable
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel
import com.tangem.features.onramp.tokenlist.ui.TokenList
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.ui.onrampTokenList
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.StateFlow
@Stable
internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor(
@ -21,11 +21,11 @@ internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor(
private val model: AvailableSwapPairsModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
override val uiState: StateFlow<TokenListUM>
get() = model.state
TokenList(state = state, modifier = modifier)
override fun LazyListScope.content(uiState: TokenListUM, modifier: Modifier) {
onrampTokenList(state = uiState)
}
@AssistedFactory

View file

@ -0,0 +1,29 @@
package com.tangem.features.onramp.swap.availablepairs.entity.converters
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.AccountStatus
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
internal class LoadingAccountTokenItemConverter(
private val appCurrency: AppCurrency,
) : Converter<AccountStatus.CryptoPortfolio, TokensListItemUM.Portfolio> {
override fun convert(value: AccountStatus.CryptoPortfolio): TokensListItemUM.Portfolio {
val (account, currencies) = value
return TokensListItemUM.Portfolio(
tokenItemUM = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = account,
onItemClick = null,
).convert(TotalFiatBalance.Failed),
isExpanded = true,
isCollapsable = false,
tokens = currencies.flattenCurrencies().map(LoadingTokenListItemConverter::convert).toPersistentList(),
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.onramp.swap.availablepairs.entity.transformers
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
internal class SetNoAvailablePairsTransformerV2(
private val appCurrency: AppCurrency,
private val accountList: Map<Account.CryptoPortfolio, List<CryptoCurrencyStatus>>,
private val isBalanceHidden: Boolean,
private val isAccountsMode: Boolean,
private val unavailableErrorText: TextReference,
) : TokenListUMTransformer {
private val unavailableConverter = OnrampTokenItemStateConverterFactory
.createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText)
override fun transform(prevState: TokenListUM): TokenListUM {
return prevState.copy(
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = if (isAccountsMode) {
TokenListUMData.AccountList(
tokensList = accountList.map { (account, cryptoCurrencies) ->
TokensListItemUM.Portfolio(
tokenItemUM = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = account,
onItemClick = null,
).convert(TotalFiatBalance.Failed),
isExpanded = true,
isCollapsable = false,
tokens = unavailableConverter.convertList(cryptoCurrencies)
.map(TokensListItemUM::Token)
.toPersistentList(),
)
}.toPersistentList(),
)
} else {
TokenListUMData.TokenList(
tokensList = accountList.flatMap { (_, cryptoCurrencies) ->
unavailableConverter.convertList(cryptoCurrencies)
.map(TokensListItemUM::Token)
}.toPersistentList(),
)
},
isBalanceHidden = isBalanceHidden,
warning = NotificationUM.Warning.SwapNoAvailablePair,
)
}
}

View file

@ -1,12 +1,15 @@
package com.tangem.features.onramp.swap.availablepairs.model
import arrow.core.getOrElse
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.extensions.capitalize
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
@ -15,6 +18,8 @@ import com.tangem.domain.core.utils.getOrElse
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
@ -28,11 +33,13 @@ import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponen
import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer
import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetLoadingTokenItemsTransformer
import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformer
import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformerV2
import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM
import com.tangem.features.onramp.swap.entity.AccountCurrencyUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMController
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.*
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
@ -43,7 +50,7 @@ import javax.inject.Inject
private typealias AvailablePairsState = Lce<Throwable, List<SwapPairLeast>>
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class AvailableSwapPairsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
@ -53,7 +60,10 @@ internal class AvailableSwapPairsModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getAvailablePairsUseCase: GetAvailablePairsUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
val state: StateFlow<TokenListUM> = tokenListUMController.state
@ -62,13 +72,17 @@ internal class AvailableSwapPairsModel @Inject constructor(
private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
private val tokenListFlow = getTokenListUseCaseFlow()
private val accountListFlow = getAccountListUseCaseFlow()
private val availablePairsByNetworkFlow = MutableStateFlow<Map<LeastTokenInfo, AvailablePairsState>>(emptyMap())
init {
initializeSearchBarCallbacks()
if (accountsFeatureToggles.isFeatureEnabled) {
subscribeOnUpdateStateV2()
} else {
subscribeOnUpdateState()
}
subscribeOnUpdateState()
initializeSearchBarCallbacks()
subscribeOnAvailablePairsUpdates()
}
@ -79,12 +93,20 @@ internal class AvailableSwapPairsModel @Inject constructor(
maybeTokenList.getOrElse(
ifLoading = { it ?: TokenList.Empty },
ifError = { TokenList.Empty },
)
.flattenCurrencies()
).flattenCurrencies()
}
.shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1)
}
private fun getAccountListUseCaseFlow(): SharedFlow<List<AccountStatus>> {
return singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(params.userWalletId))
.distinctUntilChanged()
.map { accountStatusList ->
accountStatusList.accountStatuses.toList()
}.flowOn(dispatchers.default)
.shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1)
}
private fun initializeSearchBarCallbacks() {
tokenListUMController.update(
transformer = UpdateSearchBarCallbacksTransformer(
@ -130,6 +152,53 @@ internal class AvailableSwapPairsModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeOnUpdateStateV2() {
combine(
flow = getAccountsAndModeFlow(),
flow2 = getAppCurrencyAndBalanceHidingFlow(),
flow3 = params.selectedStatus,
flow4 = searchManager.query,
flow5 = availablePairsByNetworkFlow
.map { it[params.selectedStatus.value?.toLeastTokenInfo()] }
.distinctUntilChanged(),
) { accountListAndMode, appCurrencyAndBalanceHiding, selectedStatus, query, availablePairsState ->
val (accountList, isAccountsMode) = accountListAndMode
availablePairsState?.fold(
ifLoading = {
SetLoadingAccountTokenListTransformer(
appCurrency = appCurrencyAndBalanceHiding.first,
accountList = accountList,
isAccountsMode = isAccountsMode,
)
},
ifContent = { pairs ->
handleContentStateV2(
appCurrencyAndBalanceHiding = appCurrencyAndBalanceHiding,
accountList = accountList,
selectedStatus = selectedStatus,
query = query,
availablePairs = pairs,
isAccountsMode = isAccountsMode,
)
},
ifError = {
handleErrorStateV2(
cause = it,
networkInfo = params.selectedStatus.value?.toLeastTokenInfo(),
accountList = accountList,
)
},
) ?: SetLoadingAccountTokenListTransformer(
appCurrency = appCurrencyAndBalanceHiding.first,
accountList = accountList,
isAccountsMode = isAccountsMode,
)
}
.onEach(tokenListUMController::update)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun handleContentState(
appCurrencyAndBalanceHiding: Pair<AppCurrency, Boolean>,
currencies: List<CryptoCurrencyStatus>,
@ -176,6 +245,53 @@ internal class AvailableSwapPairsModel @Inject constructor(
}
}
private fun handleContentStateV2(
appCurrencyAndBalanceHiding: Pair<AppCurrency, Boolean>,
accountList: List<AccountStatus>,
selectedStatus: CryptoCurrencyStatus?,
query: String,
availablePairs: List<SwapPairLeast>,
isAccountsMode: Boolean,
): TokenListUMTransformer {
val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding
val filterByQueryAccountList = accountList.associate { accountStatus ->
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.account to accountStatus.tokenList.flattenCurrencies()
.filter { it.currency != selectedStatus?.currency }
.filterByQuery(query = query)
}
}
if (availablePairs.isEmpty()) {
return SetNoAvailablePairsTransformerV2(
appCurrency = appCurrency,
accountList = filterByQueryAccountList,
unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header),
isBalanceHidden = isBalanceHidden,
isAccountsMode = isAccountsMode,
)
}
return if (query.isNotEmpty() && filterByQueryAccountList.isEmpty()) {
SetNothingToFoundStateTransformerV2(
isBalanceHidden = isBalanceHidden,
emptySearchMessageReference = resourceReference(
id = R.string.action_buttons_swap_empty_search_message,
),
)
} else {
UpdateAccountTokenListTransformer(
appCurrency = appCurrency,
onItemClick = params.onTokenClick,
accountList = filterByQueryAccountList.filterByAvailability(availablePairs = availablePairs),
isBalanceHidden = isBalanceHidden,
unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header),
isAccountsMode = isAccountsMode,
)
}
}
private fun handleErrorState(
cause: Throwable,
networkInfo: LeastTokenInfo?,
@ -193,6 +309,26 @@ internal class AvailableSwapPairsModel @Inject constructor(
)
}
private fun handleErrorStateV2(
cause: Throwable,
networkInfo: LeastTokenInfo?,
accountList: List<AccountStatus>,
): SetErrorWarningTransformer {
return SetErrorWarningTransformer(
cause = cause,
onRefresh = {
modelScope.launch {
if (networkInfo != null) {
accountList.filterIsInstance<AccountStatus.CryptoPortfolio>()
.forEach { (_, currencies) ->
updateAvailablePairs(networkInfo, currencies.flattenCurrencies())
}
}
}
},
)
}
private fun subscribeOnAvailablePairsUpdates() {
modelScope.launch {
params.selectedStatus
@ -203,9 +339,19 @@ internal class AvailableSwapPairsModel @Inject constructor(
val isAlreadyLoaded = availablePairsByNetworkFlow.value[networkInfo]?.isContent() == true
if (isAlreadyLoaded) return@collectLatest
val statuses = tokenListFlow.firstOrNull() ?: return@collectLatest
updateAvailablePairs(networkInfo = networkInfo, statuses = statuses)
if (accountsFeatureToggles.isFeatureEnabled) {
val accountList = accountListFlow.firstOrNull() ?: return@collectLatest
updateAvailablePairs(
networkInfo = networkInfo,
statuses = accountList.filterIsInstance<AccountStatus.CryptoPortfolio>()
.flatMap { accountStatus ->
accountStatus.flattenCurrencies()
}.toSet().toList(),
)
} else {
val statuses = tokenListFlow.firstOrNull() ?: return@collectLatest
updateAvailablePairs(networkInfo = networkInfo, statuses = statuses)
}
}
}
}
@ -247,6 +393,14 @@ internal class AvailableSwapPairsModel @Inject constructor(
)
}
private fun getAccountsAndModeFlow(): Flow<Pair<List<AccountStatus>, Boolean>> {
return combine(
flow = accountListFlow.distinctUntilChanged(),
flow2 = isAccountsModeEnabledUseCase().distinctUntilChanged(),
transform = ::Pair,
)
}
private fun onSearchQueryChange(newQuery: String) {
if (state.value.searchBarUM.query == newQuery) return
@ -286,6 +440,29 @@ internal class AvailableSwapPairsModel @Inject constructor(
}
}
private fun Map<Account.CryptoPortfolio, List<CryptoCurrencyStatus>>.filterByAvailability(
availablePairs: List<SwapPairLeast>,
): List<AccountAvailabilityUM> {
return map { (account, currencies) ->
AccountAvailabilityUM(
account = account,
currencyList = currencies.map { status ->
val isAvailable = availablePairs.map(SwapPairLeast::to).contains(status.toLeastTokenInfo())
val isAvailableToSwap = isAvailable &&
status.value !is CryptoCurrencyStatus.MissedDerivation &&
status.value !is CryptoCurrencyStatus.Unreachable &&
!status.currency.isCustom
AccountCurrencyUM(
cryptoCurrencyStatus = status,
isAvailable = isAvailableToSwap,
)
},
)
}
}
private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo {
return LeastTokenInfo(
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0",

View file

@ -0,0 +1,14 @@
package com.tangem.features.onramp.swap.entity
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
internal data class AccountAvailabilityUM(
val account: Account.CryptoPortfolio,
val currencyList: List<AccountCurrencyUM>,
)
internal data class AccountCurrencyUM(
val isAvailable: Boolean,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
)

View file

@ -1,5 +1,7 @@
package com.tangem.features.onramp.swap.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.CryptoPortfolioIconUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
@ -11,7 +13,7 @@ import com.tangem.core.ui.extensions.TextReference
internal sealed interface ExchangeCardUM {
/** Title reference */
val titleReference: TextReference
val titleUM: TitleUM
/** Remove button UI model */
val removeButtonUM: RemoveButtonUM?
@ -19,11 +21,11 @@ internal sealed interface ExchangeCardUM {
/**
* Empty state
*
* @property titleReference title reference
* @property titleUM title reference
* @property subtitleReference empty token subtitle reference
*/
data class Empty(
override val titleReference: TextReference,
override val titleUM: TitleUM,
val subtitleReference: TextReference,
) : ExchangeCardUM {
@ -33,15 +35,29 @@ internal sealed interface ExchangeCardUM {
/**
* Filled
*
* @property titleReference title reference
* @property titleUM title reference
* @property removeButtonUM remove button UI model
* @property tokenItemState token item state
*/
data class Filled(
override val titleReference: TextReference,
override val titleUM: TitleUM,
override val removeButtonUM: RemoveButtonUM?,
val tokenItemState: TokenItemState,
) : ExchangeCardUM
data class RemoveButtonUM(val onClick: () -> Unit)
@Immutable
sealed interface TitleUM {
data class Text(
val title: TextReference,
) : TitleUM
data class Account(
val prefixText: TextReference,
val name: TextReference,
val icon: CryptoPortfolioIconUM,
) : TitleUM
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.onramp.swap.entity.transformer
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.domain.models.account.Account
import com.tangem.features.onramp.swap.entity.ExchangeCardUM
import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM
import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer
@ -17,6 +18,8 @@ import com.tangem.features.onramp.swap.entity.utils.toFilled
internal class SelectFromTokenTransformer(
private val selectedTokenItemState: TokenItemState,
private val onRemoveClick: () -> Unit,
private val account: Account.CryptoPortfolio?,
private val isAccountsMode: Boolean,
) : SwapSelectTokensUMTransformer {
override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM {
@ -24,6 +27,9 @@ internal class SelectFromTokenTransformer(
exchangeFrom = prevState.exchangeFrom.toFilled(
selectedTokenItemState = selectedTokenItemState,
removeButtonUM = ExchangeCardUM.RemoveButtonUM(onClick = onRemoveClick),
account = account,
isAccountsMode = isAccountsMode,
isFromCurrency = true,
),
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.onramp.swap.entity.transformer
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.domain.models.account.Account
import com.tangem.features.onramp.swap.entity.ExchangeCardUM
import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM
import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer
@ -15,12 +16,19 @@ import com.tangem.features.onramp.swap.entity.utils.toFilled
*/
internal class SelectToTokenTransformer(
private val selectedTokenItemState: TokenItemState,
private val isAccountsMode: Boolean,
private val account: Account.CryptoPortfolio?,
) : SwapSelectTokensUMTransformer {
override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM {
return prevState.copy(
exchangeFrom = prevState.exchangeFrom.hideRemoveButton(),
exchangeTo = prevState.exchangeTo.toFilled(selectedTokenItemState = selectedTokenItemState),
exchangeTo = prevState.exchangeTo.toFilled(
selectedTokenItemState = selectedTokenItemState,
isAccountsMode = isAccountsMode,
account = account,
isFromCurrency = false,
),
)
}

View file

@ -1,14 +1,16 @@
package com.tangem.features.onramp.swap.entity.utils
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.Account
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.swap.entity.ExchangeCardUM
/** Create empty exchange "from" card */
internal fun createEmptyExchangeFrom(): ExchangeCardUM.Empty {
return ExchangeCardUM.Empty(
titleReference = resourceReference(id = R.string.swapping_from_title),
titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)),
subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_swap),
)
}
@ -16,7 +18,7 @@ internal fun createEmptyExchangeFrom(): ExchangeCardUM.Empty {
/** Create empty exchange "to" card */
internal fun createEmptyExchangeTo(): ExchangeCardUM.Empty {
return ExchangeCardUM.Empty(
titleReference = resourceReference(id = R.string.swapping_to_title),
titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_to_title)),
subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_receive),
)
}
@ -29,10 +31,25 @@ internal fun createEmptyExchangeTo(): ExchangeCardUM.Empty {
*/
internal fun ExchangeCardUM.toFilled(
selectedTokenItemState: TokenItemState,
account: Account.CryptoPortfolio?,
isAccountsMode: Boolean,
isFromCurrency: Boolean,
removeButtonUM: ExchangeCardUM.RemoveButtonUM? = null,
): ExchangeCardUM.Filled {
return ExchangeCardUM.Filled(
titleReference = titleReference,
titleUM = if (account != null && isAccountsMode) {
ExchangeCardUM.TitleUM.Account(
prefixText = if (isFromCurrency) {
resourceReference(R.string.common_from)
} else {
resourceReference(R.string.common_to)
},
name = account.accountName.toUM().value,
icon = account.icon.toUM(),
)
} else {
titleUM
},
tokenItemState = selectedTokenItemState,
removeButtonUM = removeButtonUM,
)

View file

@ -8,7 +8,10 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.onramp.component.SwapSelectTokensComponent
import com.tangem.features.onramp.swap.entity.SwapSelectTokensController
@ -32,6 +35,8 @@ internal class SwapSelectTokensModel @Inject constructor(
private val router: Router,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
) : Model() {
val state: StateFlow<SwapSelectTokensUM> = controller.state
@ -43,9 +48,13 @@ internal class SwapSelectTokensModel @Inject constructor(
private val params = paramsContainer.require<SwapSelectTokensComponent.Params>()
private var isAccountsMode: Boolean = false
private var account: Account.CryptoPortfolio? = null
init {
controller.update { it.copy(onBackClick = ::onBackClick) }
subscribeOnAccountsMode()
subscribeOnBalanceHidingSettings()
}
@ -62,12 +71,19 @@ internal class SwapSelectTokensModel @Inject constructor(
_fromCurrencyStatus.value = status
controller.update(
transformer = SelectFromTokenTransformer(
selectedTokenItemState = selectedTokenItemState,
onRemoveClick = ::onRemoveFromTokenClick,
),
)
modelScope.launch {
controller.update(
transformer = SelectFromTokenTransformer(
selectedTokenItemState = selectedTokenItemState,
onRemoveClick = ::onRemoveFromTokenClick,
isAccountsMode = isAccountsMode,
account = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = params.userWalletId,
currency = status.currency,
).getOrNull()?.account,
),
)
}
}
/**
@ -84,7 +100,16 @@ internal class SwapSelectTokensModel @Inject constructor(
modelScope.launch {
_toCurrencyStatus.value = status
controller.update(transformer = SelectToTokenTransformer(selectedTokenItemState))
controller.update(
transformer = SelectToTokenTransformer(
selectedTokenItemState = selectedTokenItemState,
isAccountsMode = isAccountsMode,
account = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = params.userWalletId,
currency = status.currency,
).getOrNull()?.account,
),
)
// require some delay to show state with selected "from" and "to" tokens
delay(timeMillis = 500)
@ -119,6 +144,16 @@ internal class SwapSelectTokensModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeOnAccountsMode() {
isAccountsModeEnabledUseCase()
.distinctUntilChanged()
.onEach {
isAccountsMode = it
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun onBackClick() {
analyticsEventHandler.send(
event = MainScreenAnalyticsEvent.ButtonClose(source = AnalyticsParam.ScreensSources.Swap),

View file

@ -14,11 +14,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountLabel
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.rows.NetworkTitle
@ -46,13 +49,14 @@ internal fun ExchangeCard(state: ExchangeCardUM, isBalanceHidden: Boolean, modif
modifier = modifier
.fillMaxWidth()
.heightIn(min = 116.dp)
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.primary),
verticalArrangement = Arrangement.SpaceBetween,
) {
Title(titleReference = state.titleReference, removeButtonUM = state.removeButtonUM)
Title(
titleUM = state.titleUM,
removeButtonUM = state.removeButtonUM,
)
AnimatedContent(
targetState = state,
@ -73,16 +77,39 @@ internal fun ExchangeCard(state: ExchangeCardUM, isBalanceHidden: Boolean, modif
}
@Composable
private fun Title(titleReference: TextReference, removeButtonUM: ExchangeCardUM.RemoveButtonUM?) {
private fun Title(titleUM: ExchangeCardUM.TitleUM, removeButtonUM: ExchangeCardUM.RemoveButtonUM?) {
NetworkTitle(
title = {
Text(
text = titleReference.resolveReference(),
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TangemTheme.typography.subtitle2,
)
AnimatedContent(
titleUM,
) { currentState ->
when (currentState) {
is ExchangeCardUM.TitleUM.Account -> Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = currentState.prefixText.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
AccountLabel(
name = currentState.name,
icon = currentState.icon,
iconSize = AccountIconSize.ExtraSmall,
nameStyle = TangemTheme.typography.subtitle2,
nameColor = TangemTheme.colors.text.tertiary,
)
}
is ExchangeCardUM.TitleUM.Text -> Text(
text = currentState.title.resolveReference(),
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TangemTheme.typography.subtitle2,
)
}
}
},
action = { RemoveButton(state = removeButtonUM) },
)
@ -153,7 +180,7 @@ private class ExchangeCardUMProvider : PreviewParameterProvider<ExchangeCardUM>
override val values: Sequence<ExchangeCardUM> = sequenceOf(
ExchangeCardUM.Empty(
titleReference = resourceReference(id = R.string.swapping_from_title),
titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)),
subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_swap),
),
createFilled(removeButtonUM = null),
@ -162,7 +189,7 @@ private class ExchangeCardUMProvider : PreviewParameterProvider<ExchangeCardUM>
private fun createFilled(removeButtonUM: ExchangeCardUM.RemoveButtonUM?): ExchangeCardUM.Filled {
return ExchangeCardUM.Filled(
titleReference = resourceReference(id = R.string.swapping_from_title),
titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)),
removeButtonUM = removeButtonUM,
tokenItemState = TokenItemState.Content(
id = "1",

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -23,6 +24,7 @@ import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponen
import com.tangem.features.onramp.swap.entity.ExchangeCardUM
import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM
import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
/**
* Swap select tokens
@ -39,7 +41,9 @@ import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent
internal fun SwapSelectTokens(
state: SwapSelectTokensUM,
selectFromTokenListComponent: OnrampTokenListComponent,
selectFromTokenListState: TokenListUM,
selectToTokenListComponent: AvailableSwapPairsComponent,
selectToTokenListState: TokenListUM,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = state.onBackClick)
@ -77,33 +81,33 @@ internal fun SwapSelectTokens(
}
if (state.exchangeFrom is ExchangeCardUM.Empty) {
item(key = "select_from", contentType = "select_from") {
selectFromTokenListComponent.Content(
modifier = Modifier
.padding(horizontal = 16.dp)
.animateItem(),
with(selectFromTokenListComponent) {
content(
uiState = selectFromTokenListState,
modifier = Modifier,
)
}
}
if (state.exchangeFrom is ExchangeCardUM.Filled) {
item(key = "exchange_to", contentType = "exchange_to") {
ExchangeCard(
state = state.exchangeTo,
isBalanceHidden = state.isBalanceHidden,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp)
.animateItem(),
)
if (selectToTokenListState.warning != NotificationUM.Warning.SwapNoAvailablePair) {
ExchangeCard(
state = state.exchangeTo,
isBalanceHidden = state.isBalanceHidden,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp)
.animateItem(),
)
}
}
if (state.exchangeTo is ExchangeCardUM.Empty) {
item(key = "select_to", contentType = "select_to") {
selectToTokenListComponent.Content(
modifier = Modifier
.padding(horizontal = 16.dp)
.animateItem(),
with(selectToTokenListComponent) {
content(
uiState = selectToTokenListState,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}

View file

@ -1,17 +1,17 @@
package com.tangem.features.onramp.tokenlist
import androidx.compose.runtime.Composable
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.model.OnrampTokenListModel
import com.tangem.features.onramp.tokenlist.ui.TokenList
import com.tangem.features.onramp.tokenlist.ui.onrampTokenList
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.StateFlow
@Stable
internal class DefaultOnrampTokenListComponent @AssistedInject constructor(
@ -21,11 +21,11 @@ internal class DefaultOnrampTokenListComponent @AssistedInject constructor(
private val model: OnrampTokenListModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
override val uiState: StateFlow<TokenListUM>
get() = model.state
TokenList(state = state, modifier = modifier)
override fun LazyListScope.content(uiState: TokenListUM, modifier: Modifier) {
onrampTokenList(state = uiState)
}
@AssistedFactory

View file

@ -3,14 +3,15 @@ package com.tangem.features.onramp.tokenlist
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableListContentComponent
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.onramp.tokenlist.entity.OnrampOperation
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
/** Token list component that present list of token for multi-currency wallet */
@Stable
internal interface OnrampTokenListComponent : ComposableContentComponent {
internal interface OnrampTokenListComponent : ComposableListContentComponent<TokenListUM> {
/** Component factory */
interface Factory : ComponentFactory<Params, OnrampTokenListComponent>

View file

@ -19,6 +19,19 @@ internal data class TokenListUM(
val searchBarUM: SearchBarUM,
val availableItems: ImmutableList<TokensListItemUM>,
val unavailableItems: ImmutableList<TokensListItemUM>,
val tokensListData: TokenListUMData,
val isBalanceHidden: Boolean,
val warning: NotificationUM? = null,
)
)
internal sealed interface TokenListUMData {
data class AccountList(
val tokensList: ImmutableList<TokensListItemUM.Portfolio>,
) : TokenListUMData
data class TokenList(
val tokensList: ImmutableList<TokensListItemUM>,
) : TokenListUMData
data object EmptyList : TokenListUMData
}

View file

@ -31,6 +31,7 @@ internal class TokenListUMController @Inject constructor() {
),
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = TokenListUMData.EmptyList,
isBalanceHidden = false,
),
)

View file

@ -0,0 +1,45 @@
package com.tangem.features.onramp.tokenlist.entity.transformer
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.AccountStatus
import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingAccountTokenItemConverter
import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingTokenListItemConverter
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
internal class SetLoadingAccountTokenListTransformer(
appCurrency: AppCurrency,
private val accountList: List<AccountStatus>,
private val isAccountsMode: Boolean,
) : TokenListUMTransformer {
private val accountListItemConverter = LoadingAccountTokenItemConverter(appCurrency)
override fun transform(prevState: TokenListUM): TokenListUM {
return prevState.copy(
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = if (isAccountsMode) {
TokenListUMData.AccountList(
tokensList = accountListItemConverter.convertList(
accountList.filterIsInstance<AccountStatus.CryptoPortfolio>(),
).toPersistentList(),
)
} else {
TokenListUMData.TokenList(
tokensList = accountList.flatMap { account ->
when (account) {
is AccountStatus.CryptoPortfolio -> LoadingTokenListItemConverter.convertList(
account.tokenList.flattenCurrencies(),
)
}
}.toPersistentList(),
)
},
warning = null,
)
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -26,9 +27,9 @@ internal class SetNothingToFoundStateTransformer(
id = emptySearchMessageReference.hashCode(),
text = emptySearchMessageReference,
).let(::add)
}
.toImmutableList(),
}.toImmutableList(),
unavailableItems = persistentListOf(),
tokensListData = TokenListUMData.EmptyList,
isBalanceHidden = isBalanceHidden,
)
}

View file

@ -0,0 +1,29 @@
package com.tangem.features.onramp.tokenlist.entity.transformer
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class SetNothingToFoundStateTransformerV2(
private val isBalanceHidden: Boolean,
private val emptySearchMessageReference: TextReference,
) : TokenListUMTransformer {
override fun transform(prevState: TokenListUM): TokenListUM {
return prevState.copy(
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = TokenListUMData.TokenList(tokensList = buildList {
TokensListItemUM.Text(
id = emptySearchMessageReference.hashCode(),
text = emptySearchMessageReference,
).let(::add)
}.toImmutableList()),
isBalanceHidden = isBalanceHidden,
)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.onramp.tokenlist.entity.transformer
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM
import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
internal class UpdateAccountTokenItemConverter(
private val appCurrency: AppCurrency,
private val unavailableErrorText: TextReference,
onItemClick: (TokenItemState, CryptoCurrencyStatus) -> Unit,
) : Converter<AccountAvailabilityUM, TokensListItemUM.Portfolio> {
private val availableConverter = OnrampTokenItemStateConverterFactory
.createAvailableItemConverter(appCurrency, onItemClick)
private val unavailableConverter = OnrampTokenItemStateConverterFactory
.createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText)
override fun convert(value: AccountAvailabilityUM): TokensListItemUM.Portfolio {
return TokensListItemUM.Portfolio(
tokenItemUM = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = value.account,
onItemClick = null,
).convert(TotalFiatBalance.Failed),
isExpanded = true,
isCollapsable = false,
tokens = value.currencyList.asSequence().map { (isAvailable, status) ->
if (isAvailable) {
availableConverter.convert(status)
} else {
unavailableConverter.convert(status)
}
}.map(TokensListItemUM::Token).toPersistentList(),
)
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.onramp.tokenlist.entity.transformer
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
internal class UpdateAccountTokenListTransformer(
private val appCurrency: AppCurrency,
private val onItemClick: (TokenItemState, CryptoCurrencyStatus) -> Unit,
private val accountList: List<AccountAvailabilityUM>,
private val isBalanceHidden: Boolean,
private val unavailableErrorText: TextReference,
private val warning: NotificationUM? = null,
private val isAccountsMode: Boolean,
) : TokenListUMTransformer {
private val accountListItemConverter = UpdateAccountTokenItemConverter(
appCurrency = appCurrency,
onItemClick = onItemClick,
unavailableErrorText = unavailableErrorText,
)
private val availableConverter = OnrampTokenItemStateConverterFactory
.createAvailableItemConverter(appCurrency, onItemClick)
private val unavailableConverter = OnrampTokenItemStateConverterFactory
.createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText)
override fun transform(prevState: TokenListUM): TokenListUM {
return prevState.copy(
availableItems = persistentListOf(),
unavailableItems = persistentListOf(),
tokensListData = if (isAccountsMode) {
TokenListUMData.AccountList(
tokensList = accountListItemConverter.convertList(accountList).toPersistentList(),
)
} else {
TokenListUMData.TokenList(
tokensList = accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { (isAvailable, status) ->
if (isAvailable) {
availableConverter.convert(status)
} else {
unavailableConverter.convert(status)
}
}.map(TokensListItemUM::Token).toPersistentList()
}.toPersistentList(),
)
},
isBalanceHidden = isBalanceHidden,
warning = warning,
)
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.getFormatte
import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -21,7 +22,13 @@ internal object OnrampTokenItemStateConverterFactory {
): TokenItemStateConverter {
return TokenItemStateConverter(
appCurrency = appCurrency,
subtitleStateProvider = { createSubtitleState(status = it, isAvailable = true) },
subtitleStateProvider = {
createSubtitleState(
status = it,
isAvailable = true,
text = stringReference(value = it.currency.symbol),
)
},
subtitle2StateProvider = ::createSubtitle2State,
fiatAmountStateProvider = {
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true)
@ -40,7 +47,13 @@ internal object OnrampTokenItemStateConverterFactory {
isAvailable = false,
)
},
subtitleStateProvider = { createSubtitleState(status = it, isAvailable = false) },
subtitleStateProvider = {
createSubtitleState(
status = it,
text = stringReference(value = it.currency.symbol),
isAvailable = false,
)
},
subtitle2StateProvider = ::createSubtitle2State,
fiatAmountStateProvider = {
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = false)
@ -48,12 +61,43 @@ internal object OnrampTokenItemStateConverterFactory {
)
}
private fun createSubtitleState(status: CryptoCurrencyStatus, isAvailable: Boolean): TokenItemState.SubtitleState {
fun createUnavailableItemConverterV2(
appCurrency: AppCurrency,
unavailableErrorText: TextReference,
): TokenItemStateConverter {
return TokenItemStateConverter(
appCurrency = appCurrency,
iconStateProvider = { CryptoCurrencyToIconStateConverter(isAvailable = false).convert(it) },
titleStateProvider = {
TokenItemState.TitleState.Content(
text = stringReference(value = it.currency.name),
isAvailable = false,
)
},
subtitleStateProvider = {
createSubtitleState(
status = it,
isAvailable = false,
text = unavailableErrorText,
)
},
subtitle2StateProvider = ::createSubtitle2State,
fiatAmountStateProvider = {
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = false)
},
)
}
private fun createSubtitleState(
status: CryptoCurrencyStatus,
isAvailable: Boolean,
text: TextReference,
): TokenItemState.SubtitleState {
return when (status.value) {
CryptoCurrencyStatus.Loading -> TokenItemState.SubtitleState.Loading
else -> {
TokenItemState.SubtitleState.TextContent(
value = stringReference(value = status.currency.symbol),
value = text,
isAvailable = isAvailable,
)
}

View file

@ -6,6 +6,11 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
@ -13,6 +18,8 @@ import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.utils.getOrElse
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
@ -23,13 +30,11 @@ import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM
import com.tangem.features.onramp.swap.entity.AccountCurrencyUM
import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent
import com.tangem.features.onramp.tokenlist.entity.OnrampOperation
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMController
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer
import com.tangem.features.onramp.tokenlist.entity.*
import com.tangem.features.onramp.tokenlist.entity.transformer.*
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
@ -42,7 +47,9 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
typealias AccountCryptoList = Map<Account.CryptoPortfolio, List<CryptoCurrencyStatus>>
@Suppress("LargeClass", "LongParameterList")
internal class OnrampTokenListModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
@ -55,6 +62,9 @@ internal class OnrampTokenListModel @Inject constructor(
private val rampStateManager: RampStateManager,
private val getUserCountryUseCase: GetUserCountryUseCase,
private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
) : Model() {
val state: StateFlow<TokenListUM> = tokenListUMController.state
@ -71,8 +81,11 @@ internal class OnrampTokenListModel @Inject constructor(
onActiveChange = ::onSearchBarActiveChange,
),
)
subscribeOnUpdateState()
if (accountsFeatureToggles.isFeatureEnabled) {
subscribeOnUpdateStateV2()
} else {
subscribeOnUpdateState()
}
}
private fun subscribeOnUpdateState() {
@ -95,12 +108,7 @@ internal class OnrampTokenListModel @Inject constructor(
if (query.isNotEmpty() && filterByQueryTokenList.isEmpty()) {
SetNothingToFoundStateTransformer(
isBalanceHidden = isBalanceHidden,
emptySearchMessageReference = when (params.filterOperation) {
OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message
OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message
OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message
}
.let(::resourceReference),
emptySearchMessageReference = getEmptySearchMessageReference(),
)
} else {
val isInsufficientBalanceForSell = if (params.filterOperation == OnrampOperation.SELL) {
@ -134,6 +142,62 @@ internal class OnrampTokenListModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeOnUpdateStateV2() {
combine(
flow = singleAccountStatusListSupplier(
SingleAccountStatusListProducer.Params(params.userWalletId),
).distinctUntilChanged(),
flow2 = getAppCurrencyAndBalanceHidingFlow(),
flow3 = isAccountsModeEnabledUseCase(),
flow4 = searchManager.query,
flow5 = hasRestrictionForSellFlow(),
) { accountList, appCurrencyAndBalanceHiding, isAccountsMode, query, hasRestrictionForSell ->
val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding
val filterByQueryAccountList = accountList.filterAccountsByQuery(query)
if (query.isNotEmpty() && filterByQueryAccountList.isEmpty()) {
updateTokenListUM(
SetNothingToFoundStateTransformerV2(
isBalanceHidden = isBalanceHidden,
emptySearchMessageReference = getEmptySearchMessageReference(),
),
)
} else {
updateTokenListUM(
SetLoadingAccountTokenListTransformer(
appCurrency = appCurrency,
accountList = accountList.accountStatuses.toList(),
isAccountsMode = isAccountsMode,
),
)
updateTokenListUM(
UpdateAccountTokenListTransformer(
appCurrency = appCurrency,
onItemClick = params.onTokenClick,
accountList = filterByQueryAccountList.filterByAvailability(),
isBalanceHidden = isBalanceHidden,
unavailableErrorText = getUnavailableTokensHeaderReference(),
warning = getSellWarning(
hasRestrictionForSell = hasRestrictionForSell,
isInsufficientBalanceForSell = accountList.isInsufficientBalanceForSell(),
),
isAccountsMode = isAccountsMode,
),
)
}
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun getAppCurrencyAndBalanceHidingFlow(): Flow<Pair<AppCurrency, Boolean>> {
return combine(
flow = getSelectedAppCurrencyUseCase().map { it.getOrElse { AppCurrency.Default } }.distinctUntilChanged(),
flow2 = getBalanceHidingSettingsUseCase().map { it.isBalanceHidden }.distinctUntilChanged(),
transform = ::Pair,
)
}
private fun hasRestrictionForSellFlow(): Flow<Boolean> {
return if (params.filterOperation == OnrampOperation.SELL) {
getUserCountryUseCase().map { maybe ->
@ -154,25 +218,52 @@ internal class OnrampTokenListModel @Inject constructor(
}
}
private fun AccountStatusList.isInsufficientBalanceForSell(): Boolean {
return if (params.filterOperation == OnrampOperation.SELL) {
(totalFiatBalance as? TotalFiatBalance.Loaded)?.amount?.isZero() == true
} else {
false
}
}
private fun getUnavailableTokensHeaderReference() = when (params.filterOperation) {
OnrampOperation.BUY -> R.string.tokens_list_unavailable_to_purchase_header
OnrampOperation.SELL -> R.string.tokens_list_unavailable_to_sell_header
OnrampOperation.SWAP -> R.string.tokens_list_unavailable_to_swap_source_header
}.let(::resourceReference)
private fun getEmptySearchMessageReference() = when (params.filterOperation) {
OnrampOperation.BUY -> R.string.action_buttons_buy_empty_search_message
OnrampOperation.SELL -> R.string.action_buttons_sell_empty_search_message
OnrampOperation.SWAP -> R.string.action_buttons_swap_empty_search_message
}.let(::resourceReference)
private fun updateTokenListUM(transformer: TokenListUMTransformer) {
tokenListUMController.update { prevState ->
transformer.transform(prevState).apply {
if (isFirstInitialization(prevState = prevState, newState = this)) {
params.onTokenListInitialized()
modelScope.launch {
tokenListUMController.update { prevState ->
transformer.transform(prevState).apply {
if (isFirstInitialization(prevState = prevState, newState = this)) {
params.onTokenListInitialized()
}
}
}
}
}
private fun getSellWarning(hasRestrictionForSell: Boolean, isInsufficientBalanceForSell: Boolean) = when {
hasRestrictionForSell -> NotificationUM.Warning.SellingRegionalRestriction
isInsufficientBalanceForSell -> NotificationUM.Warning.InsufficientBalanceForSelling
else -> null
}
private fun isFirstInitialization(prevState: TokenListUM, newState: TokenListUM): Boolean {
return prevState.availableItems.isEmpty() && prevState.unavailableItems.isEmpty() &&
(newState.availableItems.isNotEmpty() || newState.unavailableItems.isNotEmpty())
return if (accountsFeatureToggles.isFeatureEnabled) {
prevState.tokensListData == TokenListUMData.EmptyList &&
newState.tokensListData != TokenListUMData.EmptyList
} else {
prevState.availableItems.isEmpty() && prevState.unavailableItems.isEmpty() &&
(newState.availableItems.isNotEmpty() || newState.unavailableItems.isNotEmpty())
}
}
private fun onSearchQueryChange(newQuery: String) {
@ -195,6 +286,16 @@ internal class OnrampTokenListModel @Inject constructor(
)
}
private fun AccountStatusList.filterAccountsByQuery(query: String) = accountStatuses.asSequence()
.associate { accountStatus ->
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> {
val filteredList = accountStatus.tokenList.flattenCurrencies().filterByQuery(query = query)
accountStatus.account to filteredList
}
}
}.filter { (_, value) -> value.isNotEmpty() }
private fun List<CryptoCurrencyStatus>.filterByQuery(query: String): List<CryptoCurrencyStatus> {
return filter {
it.currency.name.contains(other = query, ignoreCase = true) ||
@ -237,6 +338,49 @@ internal class OnrampTokenListModel @Inject constructor(
}
}
private suspend fun AccountCryptoList.filterByAvailability(): List<AccountAvailabilityUM> {
return coroutineScope {
map { (account, currencies) ->
async {
AccountAvailabilityUM(
account = account,
currencyList = currencies.map { status ->
val isOperationAvailable = checkAvailabilityByOperation(status = status)
val isNotMissedDerivation = status.value !is CryptoCurrencyStatus.MissedDerivation
val isNotLoading = status.value !is CryptoCurrencyStatus.Loading
val requirements = getAssetRequirementsUseCase(
userWalletId = userWallet.walletId,
currency = status.currency,
).getOrNull()
val isAvailableForBuy = rampStateManager.checkAssetRequirements(requirements)
val isNotUnreachable = status.value !is CryptoCurrencyStatus.Unreachable
val isAvailable = when (params.filterOperation) {
OnrampOperation.BUY -> {
isAvailableForBuy
} // unreachable state is available for Buy operation
OnrampOperation.SELL -> isNotUnreachable
OnrampOperation.SWAP -> {
isNotUnreachable && isAvailableForBuy
}
}
val isTotalAvailable =
isOperationAvailable && isNotMissedDerivation && isNotLoading && isAvailable
AccountCurrencyUM(
cryptoCurrencyStatus = status,
isAvailable = isTotalAvailable,
)
},
)
}
}.awaitAll()
}
}
private suspend fun checkAvailabilityByOperation(status: CryptoCurrencyStatus): Boolean {
return when (params.filterOperation) {
OnrampOperation.BUY -> {

View file

@ -3,32 +3,34 @@ package com.tangem.features.onramp.tokenlist.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.tokenlist.PortfolioListItem
import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem
import com.tangem.core.ui.components.tokenlist.TokenListItem
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import com.tangem.features.onramp.tokenlist.ui.preview.PreviewTokenListUMProvider
import kotlinx.collections.immutable.ImmutableList
@ -36,17 +38,21 @@ import kotlinx.collections.immutable.ImmutableList
* Token list
*
* @param state state
* @param modifier modifier
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun TokenList(state: TokenListUM, modifier: Modifier = Modifier) {
Column(modifier) {
if (state.warning == null) {
SearchBar(searchBarUM = state.searchBarUM)
} else {
AnimatedContent(targetState = state.warning, label = "") { warning ->
internal fun LazyListScope.onrampTokenList(state: TokenListUM) {
val itemModifier = Modifier.padding(horizontal = 16.dp)
if (state.warning == null) {
searchBarItem(searchBarUM = state.searchBarUM, modifier = itemModifier)
} else {
item("NotificationsKey") {
AnimatedContent(
targetState = state.warning,
label = "",
modifier = itemModifier,
) { warning ->
when (warning) {
is NotificationUM.Warning.OnrampErrorNotification -> {
Notification(
@ -60,31 +66,45 @@ internal fun TokenList(state: TokenListUM, modifier: Modifier = Modifier) {
}
}
}
}
if (state.availableItems.isNotEmpty()) {
SpacerH12()
ItemsBlock(items = state.availableItems, isBalanceHidden = state.isBalanceHidden)
}
tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden)
if (state.unavailableItems.isNotEmpty()) {
SpacerH12()
ItemsBlock(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden)
tokensList(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden)
when (val list = state.tokensListData) {
is TokenListUMData.AccountList -> list.tokensList.forEach { item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = state.isBalanceHidden,
)
}
is TokenListUMData.TokenList -> {
tokensList(
items = list.tokensList,
isBalanceHidden = state.isBalanceHidden,
)
}
TokenListUMData.EmptyList -> Unit
}
}
@Composable
private fun SearchBar(searchBarUM: SearchBarUM) {
SearchBar(
state = searchBarUM,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
private fun LazyListScope.searchBarItem(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) {
item("SearchKey") {
SearchBar(
state = searchBarUM,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
modifier = modifier,
)
}
}
@Composable
private fun ItemsBlock(items: ImmutableList<TokensListItemUM>, isBalanceHidden: Boolean) {
items.fastForEachIndexed { index, item ->
key(item.id) {
private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isBalanceHidden: Boolean) {
itemsIndexed(
items = items,
key = { _, item -> item.id },
contentType = { _, item -> item::class.java },
itemContent = { index, item ->
TokenListItem(
state = item,
isBalanceHidden = isBalanceHidden,
@ -92,13 +112,70 @@ private fun ItemsBlock(items: ImmutableList<TokensListItemUM>, isBalanceHidden:
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = items.lastIndex,
addDefaultPadding = false,
backgroundColor = TangemTheme.colors.background.primary,
)
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.semantics { lazyListItemPosition = index },
)
}
},
)
}
internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portfolio, isBalanceHidden: Boolean) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
portfolioItem(
portfolio = portfolio,
modifier = Modifier.padding(top = 8.dp),
isBalanceHidden = isBalanceHidden,
)
if (!isExpanded) return
itemsIndexed(
items = tokens,
key = { _, item -> item.id },
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, token ->
val indexWithHeader = tokenIndex.inc()
PortfolioTokensListItem(
state = token,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
.roundedShapeItemDecoration(
currentIndex = indexWithHeader,
lastIndex = tokens.lastIndex.inc(),
backgroundColor = TangemTheme.colors.background.primary,
)
.conditional(tokenIndex == tokens.lastIndex) {
Modifier.padding(bottom = 8.dp)
},
)
},
)
}
private fun LazyListScope.portfolioItem(
portfolio: TokensListItemUM.Portfolio,
modifier: Modifier,
isBalanceHidden: Boolean,
) {
item(
key = "account-${portfolio.id}",
contentType = "account",
) {
PortfolioListItem(
state = portfolio,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
.roundedShapeItemDecoration(
currentIndex = 0,
lastIndex = portfolio.tokens.lastIndex.inc(),
backgroundColor = TangemTheme.colors.background.primary,
)
.then(modifier),
)
}
}
@ -107,12 +184,12 @@ private fun ItemsBlock(items: ImmutableList<TokensListItemUM>, isBalanceHidden:
@Composable
private fun Preview_TokenList(@PreviewParameter(PreviewTokenListUMProvider::class) state: TokenListUM) {
TangemThemePreview {
TokenList(
state = state,
modifier = Modifier
.fillMaxWidth()
.background(color = TangemTheme.colors.background.secondary)
.padding(16.dp),
)
LazyColumn(
modifier = Modifier.background(color = TangemTheme.colors.background.secondary),
) {
onrampTokenList(
state = state,
)
}
}
}

Some files were not shown because too many files have changed in this diff Show more