Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-05 14:01:26 +03:00
commit 9aacdb2ed3
695 changed files with 22022 additions and 5116 deletions

View file

@ -22,7 +22,12 @@ interface PortfolioFetcher {
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val balances: Map<UserWallet, PortfolioBalance>,
)
) {
val isSingleChoice: Boolean = balances.values
.map { it.accountsBalance.accountStatuses }
.flatten().size == 1
}
data class PortfolioBalance(
val userWallet: UserWallet,

View file

@ -1,6 +1,5 @@
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
@ -15,21 +14,20 @@ 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.status.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase
import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.core.lce.Lce
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.archived.entity.AccountArchivedUMBuilder.Companion.toggleProgress
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
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@ -61,25 +59,32 @@ internal class ArchivedAccountListModel @Inject constructor(
.conflate()
.distinctUntilChanged()
.onEach { lce ->
val newState = when (lce) {
is Lce.Content<ArchivedAccountList> -> umBuilder.mapContent(
accounts = lce.content,
onCloseClick = onCloseClick,
confirmRecoverDialog = { confirmRecoverDialog(it) },
)
is Lce.Error<Throwable> -> umBuilder.mapError(
throwable = lce.error,
onCloseClick = onCloseClick,
getArchivedAccounts = { getArchivedAccounts() },
)
is Lce.Loading<ArchivedAccountList> -> lce.partialContent?.let { content ->
val newState = lce.fold(
ifLoading = { content ->
content ?: return@fold null
umBuilder.mapContent(
accounts = content,
onCloseClick = onCloseClick,
confirmRecoverDialog = { confirmRecoverDialog(it) },
onRecoverClick = { recoverCryptoPortfolio(accountId = it.accountId) },
)
}
}
},
ifContent = { content ->
umBuilder.mapContent(
accounts = content,
onCloseClick = onCloseClick,
onRecoverClick = { recoverCryptoPortfolio(accountId = it.accountId) },
)
},
ifError = { error ->
umBuilder.mapError(
throwable = error,
onCloseClick = onCloseClick,
getArchivedAccounts = { getArchivedAccounts() },
)
},
)
newState?.let { _uiState.value = newState }
}
.flowOn(dispatchers.default)
@ -87,30 +92,13 @@ internal class ArchivedAccountListModel @Inject constructor(
.saveIn(getArchivedAccountsJob)
}
private fun confirmRecoverDialog(account: ArchivedAccount) {
val secondAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
)
val firstAction = EventMessageAction(
title = resourceReference(R.string.account_archived_recover),
onClick = { recoverCryptoPortfolio(account.accountId) },
)
messageSender.send(
DialogMessage(
title = resourceReference(R.string.account_archived_recover_dialog_title),
message = resourceReference(
id = R.string.account_archived_recover_dialog_description,
formatArgs = wrappedList(account.name.toUM().value),
),
firstActionBuilder = { firstAction },
secondActionBuilder = { secondAction },
),
)
}
private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch(dispatchers.default) {
recoverCryptoPortfolioUseCase(accountId)
private fun recoverCryptoPortfolio(accountId: AccountId) = modelScope.launch {
_uiState.update { it.toggleProgress(accountId, isLoading = true) }
val result = withContext(dispatchers.default) {
recoverCryptoPortfolioUseCase(accountId)
}
_uiState.update { it.toggleProgress(accountId, isLoading = false) }
result
.onLeft(::handleRecoverError)
.onRight {
showSuccessRecoverMessage()
@ -122,8 +110,22 @@ internal class ArchivedAccountListModel @Inject constructor(
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
val firstAction = EventMessageAction(
title = resourceReference(R.string.common_got_it),
onClick = { },
)
messageSender.send(
DialogMessage(
title = resourceReference(R.string.account_recover_limit_dialog_title),
message = resourceReference(
id = R.string.account_recover_limit_dialog_description,
formatArgs = wrappedList(AccountList.MAX_ACCOUNTS_COUNT.toString()),
),
firstActionBuilder = { firstAction },
),
)
return
}

View file

@ -24,5 +24,6 @@ internal data class ArchivedAccountUM(
val accountIconUM: CryptoPortfolioIconUM,
val tokensInfo: TextReference,
val networksInfo: TextReference,
val isLoading: Boolean,
val onClick: () -> Unit,
)

View file

@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.usecase.ArchivedAccountList
import com.tangem.domain.models.account.AccountId
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import javax.inject.Inject
@ -15,19 +16,20 @@ internal class AccountArchivedUMBuilder @Inject constructor() {
fun mapContent(
accounts: ArchivedAccountList,
onCloseClick: () -> Unit,
confirmRecoverDialog: (account: ArchivedAccount) -> Unit,
onRecoverClick: (account: ArchivedAccount) -> Unit,
) = AccountArchivedUM.Content(
onCloseClick = onCloseClick,
accounts = accounts
.map { account -> account.mapArchivedAccountUM(confirmRecoverDialog) }
.map { account -> account.mapArchivedAccountUM(onRecoverClick) }
.toImmutableList(),
)
fun ArchivedAccount.mapArchivedAccountUM(confirmRecoverDialog: (account: ArchivedAccount) -> Unit) =
private fun ArchivedAccount.mapArchivedAccountUM(onRecoverClick: (account: ArchivedAccount) -> Unit) =
ArchivedAccountUM(
accountId = accountId.value,
accountName = name.toUM().value,
accountIconUM = icon.toUM(),
isLoading = false,
tokensInfo = pluralReference(
R.plurals.common_tokens_count,
count = tokensCount,
@ -38,7 +40,7 @@ internal class AccountArchivedUMBuilder @Inject constructor() {
count = networksCount,
formatArgs = wrappedList(networksCount),
),
onClick = { confirmRecoverDialog(this) },
onClick = { onRecoverClick(this) },
)
fun mapError(
@ -52,4 +54,25 @@ internal class AccountArchivedUMBuilder @Inject constructor() {
onRetryClick = { getArchivedAccounts() },
)
}
companion object {
fun AccountArchivedUM.toggleProgress(accountId: AccountId, isLoading: Boolean): AccountArchivedUM {
return when (this) {
is AccountArchivedUM.Error,
is AccountArchivedUM.Loading,
-> this
is AccountArchivedUM.Content -> copy(
accounts = accounts.map { accountUM ->
if (accountUM.accountId == accountId.value) {
accountUM.copy(isLoading = isLoading)
} else {
accountUM
}
}.toImmutableList(),
)
}
}
}
}

View file

@ -126,7 +126,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = item.onClick)
.clickable(enabled = !item.isLoading, onClick = item.onClick)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
@ -148,6 +148,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.account_archived_recover),
isLoading = item.isLoading,
onClick = item.onClick,
),
)
@ -177,7 +178,7 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountA
tokensInfo = stringReference("10 tokens"),
networksInfo = stringReference("2 networks"),
onClick = {},
isLoading = it % 2 == 0,
)
}.toImmutableList()
val first = AccountArchivedUM.Content(

View file

@ -205,8 +205,8 @@ internal class AccountCreateEditModel @Inject constructor(
is AccountCreateEditComponent.Params.Edit -> {
val oldName = params.account.accountName.toUM()
val isNewName = this.account.name != oldName
val isNewIcon = this.account.portfolioIcon != params.account.portfolioIcon
val isNewName = this.account.name.trim() != oldName
val isNewIcon = this.account.portfolioIcon != params.account.portfolioIcon.toUM()
isValidName && (isNewName || isNewIcon)
}
}
@ -275,4 +275,9 @@ internal class AccountCreateEditModel @Inject constructor(
)
messageSender.send(dialogMessage)
}
}
private fun AccountNameUM.trim(): AccountNameUM = when (this) {
is AccountNameUM.Custom -> this.toDomain().getOrNull()?.toUM() ?: this
AccountNameUM.DefaultMain -> this
}

View file

@ -1,9 +1,9 @@
package com.tangem.features.account.createedit.error
import com.tangem.core.error.UniversalError
import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.AddCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase
import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase
sealed interface AccountFeatureError : UniversalError {

View file

@ -12,6 +12,8 @@ import com.tangem.core.ui.extensions.resourceReference
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.producer.SingleAccountProducer
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.account.usecase.ArchiveCryptoPortfolioUseCase
import com.tangem.domain.models.PortfolioId
import com.tangem.domain.models.account.Account
@ -20,12 +22,13 @@ 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
import com.tangem.features.account.details.entity.AccountDetailsUM.ArchiveMode
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class AccountDetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
@ -33,22 +36,30 @@ internal class AccountDetailsModel @Inject constructor(
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
private val archiveCryptoPortfolioUseCase: ArchiveCryptoPortfolioUseCase,
singleAccountSupplier: SingleAccountSupplier,
private val getUserWalletUseCase: GetUserWalletUseCase,
) : Model() {
private val params = paramsContainer.require<AccountDetailsComponent.Params>()
val uiState: StateFlow<AccountDetailsUM> get() = _uiState
private val _uiState: MutableStateFlow<AccountDetailsUM> = MutableStateFlow(getInitialState())
private val _uiState: MutableStateFlow<AccountDetailsUM> = MutableStateFlow(buildUI(params.account))
private val accountId = params.account.accountId
private fun onEditAccountClick() {
router.push(AppRoute.EditAccount(params.account))
init {
singleAccountSupplier(SingleAccountProducer.Params(accountId))
.onEach { account -> _uiState.update { buildUI(account) } }
.launchIn(modelScope)
}
private fun onManageTokensClick() {
private fun onEditAccountClick(account: Account) {
router.push(AppRoute.EditAccount(account))
}
private fun onManageTokensClick(account: Account) {
val route = AppRoute.ManageTokens(
source = AppRoute.ManageTokens.Source.SETTINGS,
portfolioId = PortfolioId(params.account.accountId),
portfolioId = PortfolioId(account.accountId),
)
router.push(route)
}
@ -78,7 +89,12 @@ internal class AccountDetailsModel @Inject constructor(
}
private fun archiveCryptoPortfolio() = modelScope.launch {
archiveCryptoPortfolioUseCase(params.account.accountId)
_uiState.update { it.toggleProgress(true) }
archiveCryptoPortfolioUseCase(accountId)
.onLeft { error ->
failedArchiveDialog(error)
_uiState.update { it.toggleProgress(false) }
}
.onRight {
val message = resourceReference(R.string.account_archive_success_message)
messageSender.send(ToastMessage(message = message))
@ -86,26 +102,55 @@ internal class AccountDetailsModel @Inject constructor(
}
}
private fun getInitialState(): AccountDetailsUM {
val account = params.account
private fun failedArchiveDialog(error: ArchiveCryptoPortfolioUseCase.Error) {
// todo account referral case
val titleRes = when (error) {
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated,
is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed,
-> R.string.common_something_went_wrong
}
val messageRes = when (error) {
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated,
is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed,
-> R.string.account_could_not_archive
}
val dialogMessage = DialogMessage(
title = resourceReference(titleRes),
message = resourceReference(messageRes),
)
messageSender.send(dialogMessage)
}
private fun buildUI(account: Account): AccountDetailsUM {
val archiveMode = when (account) {
is Account.CryptoPortfolio -> when (account.isMainAccount) {
true -> AccountDetailsUM.ArchiveMode.None
false -> AccountDetailsUM.ArchiveMode.Available(
true -> ArchiveMode.None
false -> ArchiveMode.Available(
onArchiveAccountClick = ::onArchiveAccountClick,
isLoading = false,
)
}
}
val isMultiCurrency = getUserWalletUseCase(params.account.accountId.userWalletId)
val isMultiCurrency = getUserWalletUseCase(account.accountId.userWalletId)
.getOrNull()?.isMultiCurrency ?: false
return AccountDetailsUM(
accountName = params.account.accountName.toUM().value,
accountIcon = params.account.portfolioIcon.toUM(),
accountName = account.accountName.toUM().value,
accountIcon = account.portfolioIcon.toUM(),
onCloseClick = { router.pop() },
onAccountEditClick = ::onEditAccountClick,
onManageTokensClick = ::onManageTokensClick,
onAccountEditClick = { onEditAccountClick(account) },
onManageTokensClick = { onManageTokensClick(account) },
archiveMode = archiveMode,
isManageTokensAvailable = isMultiCurrency,
)
}
private fun AccountDetailsUM.toggleProgress(isLoading: Boolean): AccountDetailsUM {
val archiveMode = this.archiveMode as? ArchiveMode.Available ?: return this
return this.copy(archiveMode = archiveMode.copy(isLoading = isLoading))
}
}

View file

@ -17,6 +17,7 @@ internal data class AccountDetailsUM(
data object None : ArchiveMode
data class Available(
val onArchiveAccountClick: () -> Unit,
val isLoading: Boolean,
) : ArchiveMode
}
}

View file

@ -5,6 +5,7 @@ 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.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -91,21 +92,33 @@ private fun ArchiveAccountRow(state: AccountDetailsUM.ArchiveMode.Available) {
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius12))
.background(TangemTheme.colors.background.primary)
.clickable(onClick = state.onArchiveAccountClick)
.clickable(enabled = !state.isLoading, onClick = state.onArchiveAccountClick)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Icon(
tint = TangemTheme.colors.icon.warning,
imageVector = ImageVector.vectorResource(id = R.drawable.ic_archive_24),
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.account_details_archive),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.subtitle1,
)
if (state.isLoading) {
CircularProgressIndicator(
color = TangemTheme.colors.text.disabled,
modifier = Modifier.size(TangemTheme.dimens.size24),
)
Text(
text = stringResourceSafe(R.string.account_details_archive),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.subtitle1,
)
} else {
Icon(
tint = TangemTheme.colors.icon.warning,
imageVector = ImageVector.vectorResource(id = R.drawable.ic_archive_24),
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.account_details_archive),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.subtitle1,
)
}
}
}
@ -174,6 +187,12 @@ private fun WcConnectionsContentPreview(@PreviewParameter(PreviewStateProvider::
}
}
private val archiveModeAvailable
get() = AccountDetailsUM.ArchiveMode.Available(
onArchiveAccountClick = {},
isLoading = false,
)
private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountDetailsUM>(
buildList {
val accountName = "Main"
@ -182,16 +201,13 @@ private class PreviewStateProvider : CollectionPreviewParameterProvider<AccountD
onCloseClick = {},
onAccountEditClick = {},
onManageTokensClick = {},
archiveMode = AccountDetailsUM.ArchiveMode.Available(
onArchiveAccountClick = {},
),
archiveMode = archiveModeAvailable,
accountName = stringReference(accountName),
accountIcon = portfolioIcon,
isManageTokensAvailable = true,
)
add(first)
portfolioIcon = AccountIconPreviewData.randomAccountIcon(letter = true)
add(first.copy(accountIcon = portfolioIcon))
add(first.copy(archiveMode = archiveModeAvailable.copy(isLoading = true)))
add(first.copy(archiveMode = AccountDetailsUM.ArchiveMode.None))
add(first.copy(isManageTokensAvailable = false))
},

View file

@ -41,7 +41,10 @@ internal class DefaultPortfolioFetcher @AssistedInject constructor(
get() = _mode
init {
_mode.flatMapLatest(::combineUseCases)
_mode
// reset cache if mode(StateFlow) changed
.onEach { _data.resetReplayCache() }
.flatMapLatest(::combineUseCases)
.flowOn(dispatchers.default)
.onEach { _data.emit(it) }
.launchIn(scope)

View file

@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.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.domain.models.wallet.UserWalletId
@ -42,13 +43,17 @@ internal class PortfolioSelectorModel @Inject constructor(
field = MutableStateFlow<PortfolioSelectorUM>(emptyState())
init {
val selectedAccountState = selectorController.selectedAccount
.stateIn(modelScope, started = SharingStarted.Eagerly, initialValue = null)
combine(
flow = isAccountsModeEnabledUseCase(),
flow2 = balanceFetcher.data,
flow3 = walletImageFetcher.allWallets(ArtworkSize.SMALL),
flow4 = selectorController.isEnabled,
transform = { isAccountsMode, portfolioData, artworks, isEnabled ->
val uiList = buildUiList(isAccountsMode, portfolioData, artworks, isEnabled)
flow5 = selectedAccountState,
transform = { isAccountsMode, portfolioData, artworks, isEnabled, selectedAccount ->
val uiList = buildUiList(isAccountsMode, portfolioData, artworks, isEnabled, selectedAccount)
val title = when (isAccountsMode) {
true -> resourceReference(R.string.common_choose_account)
false -> resourceReference(R.string.common_choose_wallet)
@ -68,15 +73,17 @@ internal class PortfolioSelectorModel @Inject constructor(
portfolioData: PortfolioFetcher.Data,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
isEnabled: (UserWallet, AccountStatus) -> Boolean,
selectedAccount: AccountId?,
): List<PortfolioSelectorItemUM> = when (isAccountsMode) {
true -> buildAccountsList(portfolioData, artworks, isEnabled)
false -> buildWalletList(portfolioData, artworks, isEnabled)
true -> buildAccountsList(portfolioData, artworks, isEnabled, selectedAccount)
false -> buildWalletList(portfolioData, artworks, isEnabled, selectedAccount)
}
private fun buildWalletList(
portfolioData: PortfolioFetcher.Data,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
isEnabled: (UserWallet, AccountStatus) -> Boolean,
selectedAccount: AccountId?,
): List<PortfolioSelectorItemUM> = buildList {
val appCurrency = portfolioData.appCurrency
val isBalanceHidden = portfolioData.isBalanceHidden
@ -94,12 +101,14 @@ internal class PortfolioSelectorModel @Inject constructor(
isAuthMode = false,
).convert(wallet)
if (walletItemUM.isEnabled) {
val isEnabledByFeature = isEnabled(wallet, portfolio.accountsBalance.mainAccount)
val mainAccount = portfolio.accountsBalance.mainAccount
val isEnabledByFeature = isEnabled(wallet, mainAccount)
val finalWalletItemUM =
if (isEnabledByFeature) walletItemUM else walletItemUM.copy(isEnabled = false)
add(PortfolioSelectorItemUM.Portfolio(finalWalletItemUM))
val isSelected = mainAccount.isSelected(selectedAccount)
add(PortfolioSelectorItemUM.Portfolio(finalWalletItemUM, isSelected))
} else {
lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM))
lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM, isSelected = false))
}
}
if (lockedWallets.isNotEmpty()) {
@ -116,6 +125,7 @@ internal class PortfolioSelectorModel @Inject constructor(
portfolioData: PortfolioFetcher.Data,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
isEnabled: (UserWallet, AccountStatus) -> Boolean,
selectedAccount: AccountId?,
): List<PortfolioSelectorItemUM> = buildList {
val appCurrency = portfolioData.appCurrency
val isBalanceHidden = portfolioData.isBalanceHidden
@ -133,15 +143,21 @@ internal class PortfolioSelectorModel @Inject constructor(
isAuthMode = false,
).convert(wallet)
if (!walletItemUM.isEnabled) {
lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM))
lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM, false))
return@forEach
}
val walletTitle = PortfolioSelectorItemUM.GroupTitle(
id = "GroupTitle ${wallet.walletId.stringValue}",
name = stringReference(wallet.name),
)
add(walletTitle)
when (balanceFetcher.mode.value) {
// for Wallet mode expected single portfolioData.balances
is PortfolioFetcher.Mode.Wallet -> Unit
is PortfolioFetcher.Mode.All -> {
val walletTitle = PortfolioSelectorItemUM.GroupTitle(
id = "GroupTitle ${wallet.walletId.stringValue}",
name = stringReference(wallet.name),
)
add(walletTitle)
}
}
portfolio.accountsBalance.accountStatuses.forEach { accountStatus ->
val isEnabledByFeature = isEnabled(wallet, accountStatus)
@ -156,7 +172,8 @@ internal class PortfolioSelectorModel @Inject constructor(
isEnabled = isEnabledByFeature,
isBalanceHidden = isBalanceHidden,
).convert(account)
add(PortfolioSelectorItemUM.Portfolio(accountItemUM))
val isSelected = accountStatus.isSelected(selectedAccount)
add(PortfolioSelectorItemUM.Portfolio(accountItemUM, isSelected))
}
}
if (lockedWallets.isNotEmpty()) {
@ -169,6 +186,8 @@ internal class PortfolioSelectorModel @Inject constructor(
}
}
private fun AccountStatus.isSelected(selectedId: AccountId?) = this.account.accountId == selectedId
private fun emptyState() = PortfolioSelectorUM(
items = persistentListOf(),
title = TextReference.EMPTY,

View file

@ -0,0 +1,20 @@
package com.tangem.features.account.selector.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.account.selector.PortfolioSelectorModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface PortfolioSelectorModule {
@Binds
@IntoMap
@ClassKey(PortfolioSelectorModel::class)
fun portfolioSelectorModel(model: PortfolioSelectorModel): Model
}

View file

@ -21,6 +21,7 @@ sealed interface PortfolioSelectorItemUM {
data class Portfolio(
val item: UserWalletItemUM,
val isSelected: Boolean,
) : PortfolioSelectorItemUM {
override val id: String = item.id.stringValue
}

View file

@ -1,7 +1,9 @@
package com.tangem.features.account.selector.ui
import android.content.res.Configuration
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
@ -19,6 +21,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.userwallet.UserWalletItemRow
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
@ -68,14 +71,21 @@ internal fun PortfolioSelectorContent(
)
}
val portfolioShape = RoundedCornerShape(TangemTheme.dimens.radius14)
val border = BorderStroke(
width = 1.dp,
color = TangemTheme.colors.text.accent,
)
when (item) {
is PortfolioSelectorItemUM.Portfolio -> UserWalletItemRow(
state = item.item,
modifier = offsetModifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size68)
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.clip(portfolioShape)
.background(TangemTheme.colors.background.action)
.conditional(item.isSelected) { border(border, portfolioShape) }
.clickable(enabled = item.item.isEnabled, onClick = item.item.onClick)
.padding(all = TangemTheme.dimens.spacing12)
.conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) },
@ -158,16 +168,16 @@ internal object PortfolioSelectorPreviewData {
name = stringReference("Tangem 2.0"),
).let(::add)
accountItem
.let { PortfolioSelectorItemUM.Portfolio(it) }
.let { PortfolioSelectorItemUM.Portfolio(it, false) }
.let(::add)
lockedAccountItem
.let { PortfolioSelectorItemUM.Portfolio(it) }
.let { PortfolioSelectorItemUM.Portfolio(it, false) }
.let(::add)
PortfolioSelectorItemUM.GroupTitle(
id = UUID.randomUUID().toString(),
name = stringReference("Tangem White"),
).let(::add)
accountItem.let { PortfolioSelectorItemUM.Portfolio(it) }
accountItem.let { PortfolioSelectorItemUM.Portfolio(it, true) }
.let(::add)
}
@ -177,26 +187,26 @@ internal object PortfolioSelectorPreviewData {
id = UUID.randomUUID().toString(),
name = resourceReference(R.string.common_locked_wallets),
).let(::add)
add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem))
add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem))
add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false))
add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false))
}
val walletList
get() = buildList {
add(PortfolioSelectorItemUM.Portfolio(walletItem))
add(PortfolioSelectorItemUM.Portfolio(walletItem))
add(PortfolioSelectorItemUM.Portfolio(walletItem, false))
add(PortfolioSelectorItemUM.Portfolio(walletItem, true))
}
val lockedWalletList
get() = buildList {
add(PortfolioSelectorItemUM.Portfolio(walletItem))
add(PortfolioSelectorItemUM.Portfolio(walletItem, true))
val title = PortfolioSelectorItemUM.GroupTitle(
id = UUID.randomUUID().toString(),
name = resourceReference(R.string.common_locked_wallets),
)
add(title)
add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem))
add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem))
add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false))
add(PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false))
}
}

View file

@ -135,13 +135,15 @@ internal class AskBiometryModel @Inject constructor(
params.modelCallbacks.onAllowed()
}
private fun setBiometryLockForAllWallets() {
modelScope.launch {
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
userWalletsListRepository.setLock(
userWalletId = userWallet.walletId,
lockMethod = UserWalletsListRepository.LockMethod.Biometric,
changeUnsecured = false,
private suspend fun setBiometryLockForAllWallets() {
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
userWalletsListRepository.setLock(
userWalletId = userWallet.walletId,
lockMethod = UserWalletsListRepository.LockMethod.Biometric,
changeUnsecured = false,
).onLeft {
uiMessageSender.send(
SnackbarMessage(stringReference("Something went wrong. Please contact support: $it")),
)
}
}

View file

@ -94,7 +94,7 @@ internal class CreateWalletSelectionModel @Inject constructor(
}
private fun onHardwareWalletClick() {
// TODO [REDACTED_TASK_KEY]
router.push(AppRoute.CreateHardwareWallet)
}
private fun onBuyClick() {

View file

@ -0,0 +1,9 @@
package com.tangem.features.hotwallet
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface CreateHardwareWalletComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Unit, CreateHardwareWalletComponent>
}

View file

@ -8,6 +8,7 @@ interface CreateWalletBackupComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val isUpgradeFlow: Boolean,
)
interface Factory : ComponentFactory<Params, CreateWalletBackupComponent>

View file

@ -0,0 +1,14 @@
package com.tangem.features.hotwallet
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
interface ForgetWalletComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
)
interface Factory : ComponentFactory<Params, ForgetWalletComponent>
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.hotwallet
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
interface WalletHardwareBackupComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
)
interface Factory : ComponentFactory<Params, WalletHardwareBackupComponent>
}

View file

@ -14,6 +14,7 @@ android {
dependencies {
/** Api */
implementation(projects.features.hotWallet.api)
implementation(projects.features.onboardingV2.api)
implementation(projects.features.pushNotifications.api)
/** Core modules */

View file

@ -2,6 +2,7 @@ package com.tangem.features.hotwallet.addexistingwallet.entry
import com.arkivanov.decompose.router.stack.*
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.utils.AnalyticsContextProxy
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -32,6 +33,7 @@ internal class AddExistingWalletModel @Inject constructor(
private val router: Router,
private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val analyticsContextProxy: AnalyticsContextProxy,
) : Model() {
val hotWalletStepperComponentModelCallback = HotWalletStepperComponentModelCallback()
@ -45,6 +47,15 @@ internal class AddExistingWalletModel @Inject constructor(
val startRoute = AddExistingWalletRoute.Import
val currentRoute: MutableStateFlow<AddExistingWalletRoute> = MutableStateFlow(startRoute)
init {
analyticsContextProxy.addHotWalletContext()
}
override fun onDestroy() {
super.onDestroy()
analyticsContextProxy.removeContext()
}
fun onChildBack() {
when (currentRoute.value) {
is AddExistingWalletRoute.Import -> router.pop()
@ -109,6 +120,8 @@ internal class AddExistingWalletModel @Inject constructor(
override fun onContinueClick(userWalletId: UserWalletId) {
stackNavigation.replaceAll(AddExistingWalletRoute.SetAccessCode(userWalletId))
}
override fun onUpgradeClick(userWalletId: UserWalletId) = Unit
}
inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks {

View file

@ -34,6 +34,7 @@ internal class AddExistingWalletChildFactory @Inject constructor(
params = ManualBackupCompletedComponent.Params(
userWalletId = route.userWalletId,
callbacks = model.manualBackupCompletedComponentModelCallbacks,
isUpgradeFlow = false,
),
)
is AddExistingWalletRoute.SetAccessCode -> accessCodeComponentFactory.create(

View file

@ -0,0 +1,183 @@
package com.tangem.features.hotwallet.createhardwarewallet
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.routing.AppRoute
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.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.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.hotwallet.createhardwarewallet.entity.CreateHardwareWalletUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
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 CreateHardwareWalletModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val settingsRepository: SettingsRepository,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val uiMessageSender: UiMessageSender,
private val scanCardProcessor: ScanCardProcessor,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
val uiState: StateFlow<CreateHardwareWalletUM>
field = MutableStateFlow(
CreateHardwareWalletUM(
onBackClick = { router.pop() },
onBuyTangemWalletClick = ::onBuyTangemWalletClick,
onScanDeviceClick = ::onScanDeviceClick,
),
)
override fun onDestroy() {
super.onDestroy()
}
private fun onBuyTangemWalletClick() {
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onScanDeviceClick() {
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 {
router.replaceAll(AppRoute.Wallet)
}
}
}
},
ifRight = {
setLoading(false)
sendSignedInCardAnalyticsEvent(scanResponse = scanResponse, isImported = userWallet.isImported)
router.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,36 @@
package com.tangem.features.hotwallet.createhardwarewallet
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.features.hotwallet.CreateHardwareWalletComponent
import com.tangem.features.hotwallet.createhardwarewallet.ui.CreateHardwareWalletContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
internal class DefaultCreateHardwareWalletComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Unit,
) : CreateHardwareWalletComponent, AppComponentContext by context {
private val model: CreateHardwareWalletModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
CreateHardwareWalletContent(
state = state,
modifier = modifier,
)
}
@AssistedFactory
interface Factory : CreateHardwareWalletComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultCreateHardwareWalletComponent
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.hotwallet.createhardwarewallet.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.CreateHardwareWalletComponent
import com.tangem.features.hotwallet.createhardwarewallet.CreateHardwareWalletModel
import com.tangem.features.hotwallet.createhardwarewallet.DefaultCreateHardwareWalletComponent
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)
internal interface CreateHardwareWalletModule {
@Binds
fun bindCreateHardwareWalletComponentFactory(
impl: DefaultCreateHardwareWalletComponent.Factory,
): CreateHardwareWalletComponent.Factory
@Binds
@IntoMap
@ClassKey(CreateHardwareWalletModel::class)
fun bindCreateHardwareWalletModel(model: CreateHardwareWalletModel): Model
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.hotwallet.createhardwarewallet.entity
internal data class CreateHardwareWalletUM(
val onBackClick: () -> Unit,
val onBuyTangemWalletClick: () -> Unit,
val onScanDeviceClick: () -> Unit,
val isScanInProgress: Boolean = false,
)

View file

@ -0,0 +1,130 @@
package com.tangem.features.hotwallet.createhardwarewallet.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.feature.FeatureBlock
import com.tangem.core.ui.extensions.TextReference
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.hotwallet.createhardwarewallet.entity.CreateHardwareWalletUM
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CreateHardwareWalletContent(state: CreateHardwareWalletUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.primary)
.fillMaxSize()
.systemBarsPadding(),
) {
TangemTopAppBar(
modifier = Modifier
.statusBarsPadding(),
startButton = TopAppBarButtonUM.Back(state.onBackClick),
title = TextReference.EMPTY,
)
Column(
modifier = Modifier
.weight(1f)
.padding(
start = 16.dp,
top = 24.dp,
end = 16.dp,
),
) {
Icon(
modifier = Modifier
.fillMaxWidth(),
painter = painterResource(R.drawable.ic_tangem_64),
contentDescription = null,
tint = Color.Unspecified,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
top = 20.dp,
end = 16.dp,
),
text = stringResourceSafe(R.string.wallet_create_common_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
FeatureBlock(
modifier = Modifier
.padding(top = 32.dp),
title = stringResourceSafe(R.string.hw_upgrade_key_migration_title),
description = stringResourceSafe(R.string.hw_upgrade_key_migration_description),
iconRes = R.drawable.ic_mobile_security_24,
)
FeatureBlock(
modifier = Modifier
.padding(top = 24.dp),
title = stringResourceSafe(R.string.hw_upgrade_funds_access_title),
description = stringResourceSafe(R.string.hw_upgrade_funds_access_description),
iconRes = R.drawable.ic_knight_shield_24,
)
FeatureBlock(
modifier = Modifier
.padding(top = 24.dp),
title = stringResourceSafe(R.string.hw_upgrade_general_security_title),
description = stringResourceSafe(R.string.hw_upgrade_general_security_description),
iconRes = R.drawable.ic_protect_24,
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
SecondaryButton(
modifier = Modifier
.fillMaxWidth(),
text = stringResourceSafe(R.string.details_buy_wallet),
onClick = state.onBuyTangemWalletClick,
)
PrimaryButtonIconEnd(
modifier = Modifier
.fillMaxWidth(),
text = stringResourceSafe(R.string.home_button_scan),
onClick = state.onScanDeviceClick,
iconResId = R.drawable.ic_tangem_24,
showProgress = state.isScanInProgress,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewCreateHardwareWalletContent() {
TangemThemePreview {
CreateHardwareWalletContent(
state = CreateHardwareWalletUM(
onBackClick = {},
onBuyTangemWalletClick = {},
onScanDeviceClick = {},
),
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.hotwallet.createmobilewallet
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.utils.AnalyticsContextProxy
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
@ -25,6 +26,7 @@ internal class CreateMobileWalletModel @Inject constructor(
private val saveUserWalletUseCase: SaveWalletUseCase,
private val router: Router,
private val tangemHotSdk: TangemHotSdk,
private val analyticsContextProxy: AnalyticsContextProxy,
) : Model() {
internal val uiState: StateFlow<CreateMobileWalletUM>
@ -37,6 +39,15 @@ internal class CreateMobileWalletModel @Inject constructor(
),
)
init {
analyticsContextProxy.addHotWalletContext()
}
override fun onDestroy() {
super.onDestroy()
analyticsContextProxy.removeContext()
}
private fun onImportClick() {
router.push(AppRoute.AddExistingWallet)
}

View file

@ -15,6 +15,7 @@ import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.feature.FeatureBlock
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -103,39 +104,6 @@ internal fun CreateMobileWalletContent(state: CreateMobileWalletUM, modifier: Mo
}
}
@Composable
private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
) {
Icon(
modifier = Modifier
.padding(horizontal = 12.dp),
painter = painterResource(iconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Text(
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.padding(top = 4.dp),
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -3,10 +3,13 @@ package com.tangem.features.hotwallet.createwalletbackup
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.push
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.utils.AnalyticsContextProxy
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.navigation.popTo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.hotwallet.CreateWalletBackupComponent
import com.tangem.features.hotwallet.createwalletbackup.routing.CreateWalletBackupRoute
@ -23,6 +26,7 @@ internal class CreateWalletBackupModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val analyticsContextProxy: AnalyticsContextProxy,
) : Model() {
val params = paramsContainer.require<CreateWalletBackupComponent.Params>()
@ -36,6 +40,15 @@ internal class CreateWalletBackupModel @Inject constructor(
val startRoute = CreateWalletBackupRoute.RecoveryPhraseStart
val currentRoute: MutableStateFlow<CreateWalletBackupRoute> = MutableStateFlow(startRoute)
init {
analyticsContextProxy.addHotWalletContext()
}
override fun onDestroy() {
super.onDestroy()
analyticsContextProxy.removeContext()
}
fun onBack() {
when (currentRoute.value) {
is CreateWalletBackupRoute.RecoveryPhraseStart -> router.pop()
@ -54,7 +67,7 @@ internal class CreateWalletBackupModel @Inject constructor(
}
fun onManualBackupChecked() {
stackNavigation.push(CreateWalletBackupRoute.BackupCompleted)
stackNavigation.push(CreateWalletBackupRoute.BackupCompleted(isUpgradeFlow = params.isUpgradeFlow))
}
fun onManualBackupCompleted() {
@ -83,5 +96,14 @@ internal class CreateWalletBackupModel @Inject constructor(
override fun onContinueClick(userWalletId: UserWalletId) {
onManualBackupCompleted()
}
override fun onUpgradeClick(userWalletId: UserWalletId) {
router.popTo<AppRoute.WalletSettings>()
router.push(
AppRoute.UpgradeWallet(
userWalletId = userWalletId,
),
)
}
}
}

View file

@ -16,31 +16,32 @@ internal class CreateWalletBackupChildFactory @Inject constructor() {
childContext: AppComponentContext,
model: CreateWalletBackupModel,
): ComposableContentComponent = when (route) {
CreateWalletBackupRoute.RecoveryPhraseStart -> ManualBackupStartComponent(
is CreateWalletBackupRoute.RecoveryPhraseStart -> ManualBackupStartComponent(
context = childContext,
params = ManualBackupStartComponent.Params(
callbacks = model.manualBackupStartModelCallbacks,
),
)
CreateWalletBackupRoute.RecoveryPhrase -> ManualBackupPhraseComponent(
is CreateWalletBackupRoute.RecoveryPhrase -> ManualBackupPhraseComponent(
context = childContext,
params = ManualBackupPhraseComponent.Params(
userWalletId = model.params.userWalletId,
callbacks = model.manualBackupPhraseModelCallbacks,
),
)
CreateWalletBackupRoute.ConfirmBackup -> ManualBackupCheckComponent(
is CreateWalletBackupRoute.ConfirmBackup -> ManualBackupCheckComponent(
context = childContext,
params = ManualBackupCheckComponent.Params(
userWalletId = model.params.userWalletId,
callbacks = model.manualBackupCheckModelCallbacks,
),
)
CreateWalletBackupRoute.BackupCompleted -> ManualBackupCompletedComponent(
is CreateWalletBackupRoute.BackupCompleted -> ManualBackupCompletedComponent(
context = childContext,
params = ManualBackupCompletedComponent.Params(
userWalletId = model.params.userWalletId,
callbacks = model.manualBackupCompletedModelCallbacks,
isUpgradeFlow = route.isUpgradeFlow,
),
)
}

View file

@ -15,5 +15,7 @@ internal sealed interface CreateWalletBackupRoute {
data object ConfirmBackup : CreateWalletBackupRoute
@Serializable
data object BackupCompleted : CreateWalletBackupRoute
data class BackupCompleted(
val isUpgradeFlow: Boolean,
) : CreateWalletBackupRoute
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.hotwallet.forgetwallet
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.features.hotwallet.ForgetWalletComponent
import com.tangem.features.hotwallet.forgetwallet.ui.ForgetWalletContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
internal class DefaultForgetWalletComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: ForgetWalletComponent.Params,
) : ForgetWalletComponent, AppComponentContext by context {
private val model: ForgetWalletModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ForgetWalletContent(
state = state,
modifier = modifier,
)
}
@AssistedFactory
interface Factory : ForgetWalletComponent.Factory {
override fun create(
context: AppComponentContext,
params: ForgetWalletComponent.Params,
): DefaultForgetWalletComponent
}
}

View file

@ -0,0 +1,114 @@
package com.tangem.features.hotwallet.forgetwallet
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
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.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
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.wallets.usecase.DeleteWalletUseCase
import com.tangem.features.hotwallet.ForgetWalletComponent
import com.tangem.features.hotwallet.forgetwallet.entity.ForgetWalletUM
import com.tangem.features.hotwallet.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
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
@ModelScoped
internal class ForgetWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val deleteWalletUseCase: DeleteWalletUseCase,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<ForgetWalletComponent.Params>()
internal val uiState: StateFlow<ForgetWalletUM>
field = MutableStateFlow(
ForgetWalletUM(
onBackClick = { router.pop() },
firstCheckboxChecked = false,
secondCheckboxChecked = false,
onFirstCheckboxClick = ::onFirstCheckboxClick,
onSecondCheckboxClick = ::onSecondCheckboxClick,
onForgetWalletClick = ::onForgetWalletClick,
isForgetButtonEnabled = false,
),
)
private fun onFirstCheckboxClick() {
uiState.update {
val newValue = !it.firstCheckboxChecked
it.copy(
firstCheckboxChecked = newValue,
isForgetButtonEnabled = newValue && it.secondCheckboxChecked,
)
}
}
private fun onSecondCheckboxClick() {
uiState.update {
val newValue = !it.secondCheckboxChecked
it.copy(
secondCheckboxChecked = newValue,
isForgetButtonEnabled = it.firstCheckboxChecked && newValue,
)
}
}
private fun onForgetWalletClick() {
// TODO actualize strings [REDACTED_TASK_KEY]
uiMessageSender.send(
DialogMessage(
title = stringReference("Attention"),
message = stringReference("Are you sure you want to do this?"),
firstActionBuilder = {
EventMessageAction(
title = stringReference("Forget"),
isWarning = true,
onClick = ::forgetWallet,
)
},
secondActionBuilder = {
EventMessageAction(
title = stringReference("Cancel"),
onClick = {},
)
},
),
)
}
private fun forgetWallet() {
modelScope.launch {
val hasUserWallets = deleteWalletUseCase(params.userWalletId)
.getOrElse {
Timber.e("Unable to delete wallet: $it")
uiMessageSender.send(
message = SnackbarMessage(resourceReference(R.string.common_unknown_error)),
)
return@launch
}
if (hasUserWallets) {
router.pop()
} else {
router.replaceAll(AppRoute.Home())
}
}
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.hotwallet.forgetwallet.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.ForgetWalletComponent
import com.tangem.features.hotwallet.forgetwallet.DefaultForgetWalletComponent
import com.tangem.features.hotwallet.forgetwallet.ForgetWalletModel
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)
internal interface ForgetWalletModule {
@Binds
fun bindForgetWalletComponentFactory(impl: DefaultForgetWalletComponent.Factory): ForgetWalletComponent.Factory
@Binds
@IntoMap
@ClassKey(ForgetWalletModel::class)
fun bindForgetWalletModel(model: ForgetWalletModel): Model
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.hotwallet.forgetwallet.entity
internal data class ForgetWalletUM(
val onBackClick: () -> Unit,
val firstCheckboxChecked: Boolean,
val secondCheckboxChecked: Boolean,
val onFirstCheckboxClick: () -> Unit,
val onSecondCheckboxClick: () -> Unit,
val onForgetWalletClick: () -> Unit,
val isForgetButtonEnabled: Boolean,
)

View file

@ -0,0 +1,155 @@
package com.tangem.features.hotwallet.forgetwallet.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.hotwallet.forgetwallet.entity.ForgetWalletUM
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ForgetWalletContent(state: ForgetWalletUM, modifier: Modifier = Modifier) {
// TODO actualize strings [REDACTED_TASK_KEY]
Column(
modifier = modifier
.background(TangemTheme.colors.background.primary)
.fillMaxSize()
.systemBarsPadding(),
) {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
startButton = TopAppBarButtonUM.Back(state.onBackClick),
title = TextReference.EMPTY,
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Spacer(modifier = Modifier.height(16.dp))
Icon(
painter = painterResource(R.drawable.ic_attention_72),
contentDescription = null,
tint = TangemTheme.colors.icon.warning,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Attention",
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "This wallet will be permanently removed from your device",
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.height(48.dp))
CheckboxItem(
modifier = Modifier.padding(horizontal = 16.dp),
checked = state.firstCheckboxChecked,
onCheckedChange = state.onFirstCheckboxClick,
text = "I understand that removing my wallet does not delete it—but simply removes it from my device.",
)
Spacer(modifier = Modifier.height(16.dp))
CheckboxItem(
modifier = Modifier.padding(horizontal = 16.dp),
checked = state.secondCheckboxChecked,
onCheckedChange = state.onSecondCheckboxClick,
text = "I understand that if I haven't backed up my wallet before removing it, I may lose access to it.",
)
Spacer(modifier = Modifier.height(32.dp))
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
text = "Forget wallet",
onClick = state.onForgetWalletClick,
enabled = state.isForgetButtonEnabled,
)
}
}
@Composable
private fun CheckboxItem(checked: Boolean, onCheckedChange: () -> Unit, text: String, modifier: Modifier = Modifier) {
// TODO actualize strings [REDACTED_TASK_KEY]
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Top,
) {
IconToggleButton(
checked = checked,
onCheckedChange = { onCheckedChange() },
) {
AnimatedContent(
targetState = checked,
label = "Update checked state",
) { isChecked ->
Icon(
painter = painterResource(
if (isChecked) {
R.drawable.ic_accepted_20
} else {
R.drawable.ic_unticked_20
},
),
contentDescription = null,
tint = if (isChecked) {
TangemTheme.colors.control.checked
} else {
TangemTheme.colors.icon.secondary
},
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = text,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewForgetWalletContent() {
TangemThemePreview {
ForgetWalletContent(
state = ForgetWalletUM(
onBackClick = {},
firstCheckboxChecked = true,
secondCheckboxChecked = false,
onFirstCheckboxClick = {},
onSecondCheckboxClick = {},
onForgetWalletClick = {},
isForgetButtonEnabled = false,
),
)
}
}

View file

@ -29,10 +29,12 @@ internal class ManualBackupCompletedComponent @AssistedInject constructor(
interface ModelCallbacks {
fun onContinueClick(userWalletId: UserWalletId)
fun onUpgradeClick(userWalletId: UserWalletId)
}
data class Params(
val userWalletId: UserWalletId,
val callbacks: ModelCallbacks,
val isUpgradeFlow: Boolean,
)
}

View file

@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class ManualBackupCompletedModel @Inject constructor(
paramsContainer: ParamsContainer,
@ -20,7 +21,14 @@ internal class ManualBackupCompletedModel @Inject constructor(
internal val uiState: StateFlow<ManualBackupCompletedUM>
field = MutableStateFlow(
ManualBackupCompletedUM(
onContinueClick = { params.callbacks.onContinueClick(params.userWalletId) },
onContinueClick = {
if (params.isUpgradeFlow) {
params.callbacks.onUpgradeClick(params.userWalletId)
} else {
params.callbacks.onContinueClick(params.userWalletId)
}
},
isLoading = false,
),
)
}

View file

@ -2,4 +2,5 @@ package com.tangem.features.hotwallet.manualbackup.completed.entity
internal data class ManualBackupCompletedUM(
val onContinueClick: () -> Unit,
val isLoading: Boolean,
)

View file

@ -65,8 +65,6 @@ internal fun ManualBackupCompletedContent(state: ManualBackupCompletedUM, modifi
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_continue),
showProgress = false,
enabled = true,
onClick = state.onContinueClick,
)
}
@ -75,11 +73,26 @@ internal fun ManualBackupCompletedContent(state: ManualBackupCompletedUM, modifi
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewManualBackupCompletedContent() {
private fun PreviewManualBackupCompletedContentRegular() {
TangemThemePreview {
ManualBackupCompletedContent(
state = ManualBackupCompletedUM(
onContinueClick = {},
isLoading = false,
onContinueClick = { },
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewManualBackupCompletedContentUpgrade() {
TangemThemePreview {
ManualBackupCompletedContent(
state = ManualBackupCompletedUM(
isLoading = false,
onContinueClick = { },
),
)
}

View file

@ -4,16 +4,15 @@ import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.feature.FeatureBlock
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -91,39 +90,6 @@ internal fun ManualBackupStartContent(state: ManualBackupStartUM, modifier: Modi
}
}
@Composable
private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
) {
Icon(
modifier = Modifier
.padding(horizontal = 12.dp),
painter = painterResource(iconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Text(
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.padding(top = 4.dp),
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -5,21 +5,39 @@ 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.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.hotwallet.UpgradeWalletComponent
import com.tangem.features.hotwallet.upgradewallet.ui.UpgradeWalletContent
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@Suppress("UnusedPrivateMember")
internal class DefaultUpgradeWalletComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: UpgradeWalletComponent.Params,
resetCardsComponentFactory: ResetCardsComponent.Factory,
) : UpgradeWalletComponent, AppComponentContext by context {
private val model: UpgradeWalletModel = getOrCreateModel(params)
private val resetCardsComponent = resetCardsComponentFactory.create(
context = child("ResetCardsComponent"),
params = ResetCardsComponent.Params(
callbacks = model.resetCardsComponentCallbacks,
),
)
init {
model.startResetCardsFlow
.onEach { resetCardsComponent.startResetCardsFlow(it) }
.launchIn(componentScope)
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()

View file

@ -16,20 +16,25 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.toWrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.card.BackupValidator
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.ClearHotWalletContextualUnlockUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.features.hotwallet.UpgradeWalletComponent
import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM
import com.tangem.features.onboarding.v2.util.ResetCardsComponent
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.extensions.localizedDescriptionRes
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@ -50,17 +55,21 @@ internal class UpgradeWalletModel @Inject constructor(
private val clearHotWalletContextualUnlockUseCase: ClearHotWalletContextualUnlockUseCase,
private val tangemSdkManager: TangemSdkManager,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
) : Model() {
private val params = paramsContainer.require<UpgradeWalletComponent.Params>()
private val _uiState = MutableStateFlow(
UpgradeWalletUM(
onBackClick = { router.pop() },
onBuyTangemWalletClick = ::onBuyTangemWalletClick,
onScanDeviceClick = ::onScanDeviceClick,
),
)
internal val uiState: StateFlow<UpgradeWalletUM> = _uiState
val resetCardsComponentCallbacks = ResetCardsModelCallbacks()
val startResetCardsFlow = MutableSharedFlow<UserWallet.Cold>()
internal val uiState: StateFlow<UpgradeWalletUM>
field = MutableStateFlow(
UpgradeWalletUM(
onBackClick = { router.pop() },
onBuyTangemWalletClick = ::onBuyTangemWalletClick,
onContinueClick = ::onContinueClick,
),
)
override fun onDestroy() {
clearHotWalletContextualUnlockUseCase.invoke(params.userWalletId)
@ -73,7 +82,7 @@ internal class UpgradeWalletModel @Inject constructor(
}
}
private fun onScanDeviceClick() {
private fun onContinueClick() {
scanCard()
}
@ -89,6 +98,13 @@ internal class UpgradeWalletModel @Inject constructor(
tangemSdkManager
.scanProduct()
.doOnSuccess {
// Check if user attempted to upgrade before but something went wrong and a full reset is required
val userWallet = coldUserWalletBuilderFactory.create(it).build()
if (userWallet?.walletId == params.userWalletId && BackupValidator.isValidFull(it.card).not()) {
startResetCardsFlow.emit(userWallet)
return@doOnSuccess
}
delay(DELAY_SDK_DIALOG_CLOSE)
tangemSdkManager.changeDisplayedCardIdNumbersCount(it)
navigateToUpgradeFlow(it)
@ -100,7 +116,7 @@ internal class UpgradeWalletModel @Inject constructor(
}
private fun setLoading(isLoading: Boolean) {
_uiState.update { it.copy(isLoading = isLoading) }
uiState.update { it.copy(isLoading = isLoading) }
}
private fun showCardVerificationFailedDialog(error: TangemError) {
@ -143,4 +159,14 @@ internal class UpgradeWalletModel @Inject constructor(
),
)
}
inner class ResetCardsModelCallbacks : ResetCardsComponent.ModelCallbacks {
override fun onCancel() {
setLoading(false)
}
override fun onComplete() {
setLoading(false)
}
}
}

View file

@ -3,6 +3,6 @@ package com.tangem.features.hotwallet.upgradewallet.entity
internal data class UpgradeWalletUM(
val onBackClick: () -> Unit,
val onBuyTangemWalletClick: () -> Unit,
val onScanDeviceClick: () -> Unit,
val onContinueClick: () -> Unit,
val isLoading: Boolean = false,
)

View file

@ -3,7 +3,9 @@ package com.tangem.features.hotwallet.upgradewallet.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -16,6 +18,7 @@ import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.feature.FeatureBlock
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -102,44 +105,11 @@ internal fun UpgradeWalletContent(state: UpgradeWalletUM, modifier: Modifier = M
onClick = state.onBuyTangemWalletClick,
)
PrimaryButtonIconEnd(
modifier = Modifier
.fillMaxWidth(),
text = stringResourceSafe(R.string.hw_upgrade_scan_device),
onClick = state.onScanDeviceClick,
modifier = Modifier.fillMaxWidth(),
iconResId = R.drawable.ic_tangem_24,
)
}
}
}
@Composable
private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
) {
Icon(
modifier = Modifier
.padding(horizontal = 12.dp),
painter = painterResource(iconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Text(
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.padding(top = 4.dp),
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
text = stringResourceSafe(R.string.hw_upgrade_start_action),
onClick = state.onContinueClick,
showProgress = state.isLoading,
)
}
}
@ -148,13 +118,28 @@ private fun FeatureBlock(title: String, description: String, iconRes: Int, modif
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewUpgradeWalletContent() {
private fun PreviewUpgradeWalletContentBackup() {
TangemThemePreview {
UpgradeWalletContent(
state = UpgradeWalletUM(
onBackClick = {},
onBuyTangemWalletClick = {},
onScanDeviceClick = {},
onContinueClick = {},
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewUpgradeWalletContentScan() {
TangemThemePreview {
UpgradeWalletContent(
state = UpgradeWalletUM(
onBackClick = {},
onBuyTangemWalletClick = {},
onContinueClick = {},
),
)
}

View file

@ -4,6 +4,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.push
import com.arkivanov.decompose.router.stack.replaceAll
import com.tangem.core.analytics.utils.AnalyticsContextProxy
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -39,6 +40,7 @@ internal class WalletActivationModel @Inject constructor(
private val router: Router,
private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val analyticsContextProxy: AnalyticsContextProxy,
) : Model() {
val params = paramsContainer.require<WalletActivationComponent.Params>()
@ -56,6 +58,15 @@ internal class WalletActivationModel @Inject constructor(
val startRoute = WalletActivationRoute.ManualBackupStart
val currentRoute: MutableStateFlow<WalletActivationRoute> = MutableStateFlow(startRoute)
init {
analyticsContextProxy.addHotWalletContext()
}
override fun onDestroy() {
super.onDestroy()
analyticsContextProxy.removeContext()
}
fun onChildBack() {
when (currentRoute.value) {
is WalletActivationRoute.ManualBackupStart -> router.pop()
@ -136,6 +147,8 @@ internal class WalletActivationModel @Inject constructor(
override fun onContinueClick(userWalletId: UserWalletId) {
stackNavigation.push(WalletActivationRoute.SetAccessCode)
}
override fun onUpgradeClick(userWalletId: UserWalletId) = Unit
}
inner class AccessCodeModelCallbacks : AccessCodeComponent.ModelCallbacks {

View file

@ -50,6 +50,7 @@ internal class WalletActivationChildFactory @Inject constructor(
params = ManualBackupCompletedComponent.Params(
userWalletId = model.params.userWalletId,
callbacks = model.manualBackupCompletedModelCallbacks,
isUpgradeFlow = false,
),
)
is WalletActivationRoute.SetAccessCode -> accessCodeComponentFactory.create(

View file

@ -7,6 +7,7 @@ internal data class WalletBackupUM(
val recoveryPhraseOption: LabelUM?,
val googleDriveOption: LabelUM?,
val googleDriveStatus: BackupStatus,
val onBuyClick: () -> Unit,
val onRecoveryPhraseClick: () -> Unit,
val onGoogleDriveClick: () -> Unit,
val onHardwareWalletClick: () -> Unit,

View file

@ -1,16 +1,18 @@
package com.tangem.features.hotwallet.walletbackup.model
import com.tangem.common.routing.AppRoute
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.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.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.common.routing.AppRoute
import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase
import com.tangem.features.hotwallet.WalletBackupComponent
import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus
@ -21,12 +23,15 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class WalletBackupModel @Inject constructor(
paramsContainer: ParamsContainer,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase,
override val dispatchers: CoroutineDispatcherProvider,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val router: Router,
) : Model() {
@ -45,6 +50,7 @@ internal class WalletBackupModel @Inject constructor(
style = LabelStyle.REGULAR,
),
googleDriveStatus = BackupStatus.ComingSoon,
onBuyClick = ::onBuyClick,
onRecoveryPhraseClick = ::onRecoveryPhraseClick,
onGoogleDriveClick = { },
onHardwareWalletClick = ::onHardwareWalletClick,
@ -95,6 +101,12 @@ internal class WalletBackupModel @Inject constructor(
backedUp = userWallet.backedUp,
)
private fun onBuyClick() {
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onRecoveryPhraseClick() {
if (uiState.value.backedUp) {
getUserWalletUseCase.invoke(params.userWalletId)
@ -113,7 +125,12 @@ internal class WalletBackupModel @Inject constructor(
},
)
} else {
router.push(AppRoute.CreateWalletBackup(params.userWalletId))
router.push(
AppRoute.CreateWalletBackup(
userWalletId = params.userWalletId,
isUpgradeFlow = false,
),
)
}
}
@ -132,6 +149,6 @@ internal class WalletBackupModel @Inject constructor(
}
private fun onHardwareWalletClick() {
router.push(AppRoute.UpgradeWallet(params.userWalletId))
router.push(AppRoute.WalletHardwareBackup(params.userWalletId))
}
}

View file

@ -1,35 +1,43 @@
package com.tangem.features.hotwallet.walletbackup.ui
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.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 com.tangem.core.ui.components.appbar.AppBarWithBackButton
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.hotwallet.walletbackup.entity.BackupStatus
import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM
import com.tangem.features.hotwallet.common.ui.OptionBlock
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
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.NetworkTitle
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.ForceDarkTheme
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.hotwallet.common.ui.OptionBlock
import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus
import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Modifier) {
Column(
@ -46,11 +54,40 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
.verticalScroll(rememberScrollState())
.padding(
start = 16.dp,
top = 12.dp,
end = 16.dp,
),
) {
Banner(state)
OptionBlock(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
title = stringResourceSafe(R.string.hw_backup_hardware_title),
description = stringResourceSafe(R.string.hw_backup_hardware_description),
badge = null,
onClick = state.onHardwareWalletClick,
enabled = true,
backgroundColor = TangemTheme.colors.background.primary,
)
NetworkTitle(
modifier = Modifier
.padding(top = 8.dp),
title = {
Text(
modifier = Modifier,
text = stringResourceSafe(R.string.onboarding_create_wallet_options_button_options),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
)
OptionBlock(
modifier = Modifier,
title = stringResourceSafe(R.string.hw_backup_seed_title),
description = stringResourceSafe(R.string.hw_backup_seed_description),
badge = {
@ -60,7 +97,6 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod
enabled = true,
backgroundColor = TangemTheme.colors.background.primary,
)
OptionBlock(
modifier = Modifier
.padding(top = 8.dp),
@ -73,30 +109,125 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod
enabled = state.googleDriveStatus != BackupStatus.ComingSoon,
backgroundColor = TangemTheme.colors.background.primary,
)
NetworkTitle(
title = {
Text(
modifier = Modifier,
text = stringResourceSafe(R.string.express_provider_recommended),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
)
OptionBlock(
modifier = Modifier.fillMaxWidth(),
title = stringResourceSafe(R.string.hw_backup_hardware_title),
description = stringResourceSafe(R.string.hw_backup_hardware_description),
badge = null,
onClick = state.onHardwareWalletClick,
enabled = true,
backgroundColor = TangemTheme.colors.background.primary,
)
Spacer(modifier = Modifier.size(16.dp))
}
}
}
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun Banner(state: WalletBackupUM, modifier: Modifier = Modifier) {
ForceDarkTheme {
Column(
modifier = modifier
.background(
color = TangemTheme.colors.background.primary,
shape = RoundedCornerShape(16.dp),
),
) {
Column(
modifier = Modifier
.padding(
start = 12.dp,
top = 20.dp,
end = 12.dp,
),
) {
Text(
modifier = Modifier
.fillMaxWidth(),
text = "Tangem Wallet",
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
top = 4.dp,
),
text = "Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
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),
) {
FeatureItem(
iconResId = R.drawable.ic_shield_check_16,
text = resourceReference(R.string.welcome_create_wallet_feature_class),
)
FeatureItem(
iconResId = R.drawable.ic_flash_16,
text = resourceReference(R.string.welcome_create_wallet_feature_delivery),
)
FeatureItem(
iconResId = R.drawable.ic_sparkles_16,
text = resourceReference(R.string.welcome_create_wallet_feature_use),
)
}
Box(
modifier = Modifier
.padding(
start = 8.dp,
top = 12.dp,
end = 8.dp,
bottom = 20.dp,
),
) {
Image(
painter = painterResource(id = R.drawable.img_tangem_cards_vertical),
contentDescription = null,
)
SecondaryButton(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
text = stringResourceSafe(R.string.details_buy_wallet),
onClick = state.onBuyClick,
)
}
}
}
}
}
@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,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -119,6 +250,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
),
googleDriveStatus = BackupStatus.ComingSoon,
onBackClick = {},
onBuyClick = {},
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
@ -135,6 +267,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
),
googleDriveStatus = BackupStatus.NoBackup,
onBackClick = {},
onBuyClick = {},
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
@ -151,6 +284,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
),
googleDriveStatus = BackupStatus.Done,
onBackClick = {},
onBuyClick = {},
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},

View file

@ -0,0 +1,39 @@
package com.tangem.features.hotwallet.wallethardwarebackup.component
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.features.hotwallet.WalletHardwareBackupComponent
import com.tangem.features.hotwallet.wallethardwarebackup.model.WalletHardwareBackupModel
import com.tangem.features.hotwallet.wallethardwarebackup.ui.WalletHardwareBackupContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultWalletHardwareBackupComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: WalletHardwareBackupComponent.Params,
) : WalletHardwareBackupComponent, AppComponentContext by context {
private val model: WalletHardwareBackupModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
WalletHardwareBackupContent(
state = state,
modifier = modifier,
)
}
@AssistedFactory
interface Factory : WalletHardwareBackupComponent.Factory {
override fun create(
context: AppComponentContext,
params: WalletHardwareBackupComponent.Params,
): DefaultWalletHardwareBackupComponent
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.hotwallet.wallethardwarebackup.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.WalletHardwareBackupComponent
import com.tangem.features.hotwallet.wallethardwarebackup.component.DefaultWalletHardwareBackupComponent
import com.tangem.features.hotwallet.wallethardwarebackup.model.WalletHardwareBackupModel
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 WalletHardwareBackupModule
@Module
@InstallIn(SingletonComponent::class)
internal interface WalletHardwareBackupModuleBinds {
@Binds
@Singleton
fun bindWalletHardwareBackupComponentFactory(
impl: DefaultWalletHardwareBackupComponent.Factory,
): WalletHardwareBackupComponent.Factory
@Binds
@IntoMap
@ClassKey(WalletHardwareBackupModel::class)
fun bindWalletHardwareBackupModel(model: WalletHardwareBackupModel): Model
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.hotwallet.wallethardwarebackup.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 WalletHardwareBackupUM(
val showPurchaseBlock: Boolean = false,
val blocks: ImmutableList<Block>,
val onBackClick: () -> Unit,
val onBuyClick: () -> Unit,
) {
data class Block(
val title: TextReference,
val titleLabel: LabelUM?,
val description: TextReference,
val onClick: () -> Unit,
)
}

View file

@ -0,0 +1,143 @@
package com.tangem.features.hotwallet.wallethardwarebackup.model
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
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.components.bottomsheets.message.MessageBottomSheetUMV2
import com.tangem.core.ui.components.bottomsheets.message.icon
import com.tangem.core.ui.components.bottomsheets.message.infoBlock
import com.tangem.core.ui.components.bottomsheets.message.onClick
import com.tangem.core.ui.components.bottomsheets.message.primaryButton
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.extensions.stringReference
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.hotwallet.WalletHardwareBackupComponent
import com.tangem.features.hotwallet.impl.R
import com.tangem.features.hotwallet.wallethardwarebackup.entity.WalletHardwareBackupUM
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 javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class WalletHardwareBackupModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val messageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<WalletHardwareBackupComponent.Params>()
// TODO actualize strings [REDACTED_TASK_KEY]
private val makeBackupAtFirstAlertBS
get() = bottomSheetMessage {
infoBlock {
icon(R.drawable.ic_passcode_lock_32) {
type = MessageBottomSheetUMV2.Icon.Type.Accent
backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint
}
title = stringReference("Finish Backup First")
body = stringReference("To upgrade your wallet to hardware, back it up first.")
}
primaryButton {
text = resourceReference(R.string.hw_backup_need_action)
onClick {
router.push(
AppRoute.CreateWalletBackup(
userWalletId = params.userWalletId,
isUpgradeFlow = true,
),
)
closeBs()
}
}
}
// TODO actualize strings [REDACTED_TASK_KEY]
internal val uiState: StateFlow<WalletHardwareBackupUM>
field = MutableStateFlow(
WalletHardwareBackupUM(
onBackClick = { router.pop() },
blocks = persistentListOf(
WalletHardwareBackupUM.Block(
title = stringReference("Create new wallet"),
titleLabel = LabelUM(
text = resourceReference(R.string.common_recommended),
style = LabelStyle.ACCENT,
),
description = stringReference(
"Create a new secure wallet and transfer your funds for extra protection.",
),
onClick = ::onCreateNewWalletClick,
),
WalletHardwareBackupUM.Block(
title = stringReference("Upgrade current wallet"),
titleLabel = null,
description = stringReference("Move your current wallet into Tangem Wallet."),
onClick = ::onUpgradeCurrentWalletClick,
),
),
onBuyClick = ::onBuyClick,
),
)
init {
showPurchaseBlockWithDelay()
}
private fun showPurchaseBlockWithDelay() {
modelScope.launch {
delay(SHOW_PURCHASE_BLOCK_DELAY)
uiState.update { it.copy(showPurchaseBlock = true) }
}
}
private fun onCreateNewWalletClick() {
router.push(AppRoute.CreateHardwareWallet)
}
private fun onUpgradeCurrentWalletClick() {
val userWallet = getUserWalletUseCase.invoke(params.userWalletId)
.getOrElse { error("Cannot find user wallet with id: ${params.userWalletId.stringValue}") }
if (userWallet is UserWallet.Hot) {
if (!userWallet.backedUp) {
messageSender.send(makeBackupAtFirstAlertBS)
} else {
router.push(
AppRoute.UpgradeWallet(
userWalletId = params.userWalletId,
),
)
}
}
}
private fun onBuyClick() {
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
companion object {
private const val SHOW_PURCHASE_BLOCK_DELAY = 3000L
}
}

View file

@ -0,0 +1,162 @@
package com.tangem.features.hotwallet.wallethardwarebackup.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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.SecondaryButton
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
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.stringReference
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.hotwallet.common.ui.OptionBlock
import com.tangem.features.hotwallet.impl.R
import com.tangem.features.hotwallet.wallethardwarebackup.entity.WalletHardwareBackupUM
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun WalletHardwareBackupContent(state: WalletHardwareBackupUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.fillMaxSize()
.systemBarsPadding(),
) {
TopAppBar(
modifier = Modifier
.statusBarsPadding(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = TangemTheme.colors.background.secondary,
),
navigationIcon = {
IconButton(onClick = state.onBackClick) {
Icon(
painter = painterResource(R.drawable.ic_back_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
}
},
title = {
Text(
text = stringResourceSafe(R.string.hw_backup_hardware_title),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
Column(
modifier = Modifier
.weight(1f)
.padding(
start = 16.dp,
top = 4.dp,
end = 16.dp,
),
) {
state.blocks.forEach { block ->
OptionBlock(
modifier = Modifier
.padding(top = 8.dp),
title = block.title.resolveReference(),
description = block.description.resolveReference(),
badge = block.titleLabel?.let {
{ Label(it) }
},
enabled = true,
backgroundColor = TangemTheme.colors.background.primary,
onClick = block.onClick,
)
}
}
AnimatedVisibility(state.showPurchaseBlock) {
PurchaseBlock(
onBuyClick = state.onBuyClick,
)
}
}
}
@Composable
private fun PurchaseBlock(onBuyClick: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(
horizontal = 20.dp,
vertical = 16.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
text = stringResourceSafe(R.string.wallet_add_hardware_purchase),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
SecondaryButton(
text = stringResourceSafe(R.string.wallet_import_buy_title),
onClick = onBuyClick,
size = TangemButtonSize.RoundedAction,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewWalletHardwareBackupContent() {
TangemThemePreview {
WalletHardwareBackupContent(
state = WalletHardwareBackupUM(
onBackClick = { },
blocks = persistentListOf(
WalletHardwareBackupUM.Block(
title = stringReference("Create new wallet"),
titleLabel = LabelUM(
text = resourceReference(R.string.common_recommended),
style = LabelStyle.ACCENT,
),
description = stringReference(
"Create a new secure wallet and transfer your funds for extra protection.",
),
onClick = { },
),
WalletHardwareBackupUM.Block(
title = stringReference("Upgrade current wallet"),
titleLabel = null,
description = stringReference("Move your current wallet into Tangem Wallet."),
onClick = { },
),
),
showPurchaseBlock = true,
onBuyClick = { },
),
)
}
}

View file

@ -9,9 +9,13 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase
import com.tangem.domain.managetokens.FindTokenUseCase
import com.tangem.domain.managetokens.ValidateTokenFormUseCase
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent
import com.tangem.features.managetokens.component.CustomTokenFormComponent
import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM
@ -41,6 +45,10 @@ internal class CustomTokenFormModel @Inject constructor(
private val messageSender: UiMessageSender,
private val customTokenFormManager: CustomCurrencyFormBuilder,
private val analyticsEventHandler: AnalyticsEventHandler,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase,
private val findTokenUseCase: FindTokenUseCase,
private val validateTokenFormUseCase: ValidateTokenFormUseCase,
paramsContainer: ParamsContainer,
customTokenFormUseCasesFacadeFactory: CustomTokenFormUseCasesFacade.Factory,
) : Model() {
@ -48,7 +56,13 @@ internal class CustomTokenFormModel @Inject constructor(
private val params: CustomTokenFormComponent.Params = paramsContainer.require()
private var createdCurrency: CryptoCurrency? = null
private var useCasesFacade: CustomTokenFormUseCasesFacade = customTokenFormUseCasesFacadeFactory.create(params.mode)
private val customCurrencyValidator = CustomCurrencyValidator(useCasesFacade)
private val customCurrencyValidator = CustomCurrencyValidator(
userWalletId = params.mode.userWalletId,
useCasesFacade = useCasesFacade,
createCryptoCurrencyUseCase = createCryptoCurrencyUseCase,
findTokenUseCase = findTokenUseCase,
validateTokenFormUseCase = validateTokenFormUseCase,
)
val state: MutableStateFlow<CustomTokenFormUM> = MutableStateFlow(
value = getInitialState(),
@ -164,8 +178,9 @@ internal class CustomTokenFormModel @Inject constructor(
isAlreadyAdded: Boolean,
isCustom: Boolean,
) = modelScope.launch {
val needColdWalletInteraction = useCasesFacade.needColdWalletInteraction(
network = mapOf(currency.network.backendId to getDerivationPath().value),
val needColdWalletInteraction = coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = params.mode.userWalletId,
networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value),
)
state.update { state ->

View file

@ -1,11 +1,15 @@
package com.tangem.features.managetokens.utils
import arrow.core.getOrElse
import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase
import com.tangem.domain.managetokens.FindTokenUseCase
import com.tangem.domain.managetokens.ValidateTokenFormUseCase
import com.tangem.domain.managetokens.model.AddCustomTokenForm
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.managetokens.model.exceptoin.FindTokenException
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.utils.list.CustomTokenFormUseCasesFacade
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveInAndJoin
@ -15,7 +19,11 @@ import kotlinx.coroutines.launch
import timber.log.Timber
internal class CustomCurrencyValidator(
private val userWalletId: UserWalletId,
private val useCasesFacade: CustomTokenFormUseCasesFacade,
private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase,
private val findTokenUseCase: FindTokenUseCase,
private val validateTokenFormUseCase: ValidateTokenFormUseCase,
) {
private val validateFormJobHolder = JobHolder()
@ -41,10 +49,7 @@ internal class CustomCurrencyValidator(
) = coroutineScope {
updateStatus(Status.Validating)
val result = useCasesFacade.validateTokenFormUseCase(
networkId = networkId,
formValues = formValues,
)
val result = validateTokenFormUseCase(networkId = networkId, formValues = formValues)
val validatedForm = result.getOrElse { e ->
updateStatus(Status.FormValidationException(e))
@ -90,7 +95,8 @@ internal class CustomCurrencyValidator(
updateStatus(Status.SearchingToken)
val foundToken = useCasesFacade.findTokenUseCase(
val foundToken = findTokenUseCase.invoke(
userWalletId = userWalletId,
contractAddress = validatedForm.contractAddress,
networkId = networkId,
derivationPath = derivationPath,
@ -121,7 +127,8 @@ internal class CustomCurrencyValidator(
) {
updateStatus(Status.SearchingToken)
val token = useCasesFacade.findTokenUseCase(
val token = findTokenUseCase.invoke(
userWalletId = userWalletId,
contractAddress = validatedForm.contractAddress,
networkId = networkId,
derivationPath = derivationPath,
@ -148,7 +155,8 @@ internal class CustomCurrencyValidator(
derivationPath: Network.DerivationPath,
validatedForm: AddCustomTokenForm.Validated.All?,
) {
val currency = useCasesFacade.createCryptoCurrencyUseCase(
val currency = createCryptoCurrencyUseCase.invoke(
userWalletId = userWalletId,
networkId = networkId,
derivationPath = derivationPath,
formValues = validatedForm,

View file

@ -1,54 +1,52 @@
package com.tangem.features.managetokens.utils.list
import arrow.core.Either
import arrow.core.NonEmptyList
import arrow.core.left
import arrow.core.right
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase
import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase
import com.tangem.domain.managetokens.FindTokenUseCase
import com.tangem.domain.managetokens.ValidateTokenFormUseCase
import com.tangem.domain.managetokens.model.AddCustomTokenForm
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.managetokens.model.exceptoin.FindTokenException
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase
import com.tangem.features.managetokens.component.AddCustomTokenMode
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("LongParameterList")
internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val validateTokenFormUseCase: ValidateTokenFormUseCase,
private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase,
private val findTokenUseCase: FindTokenUseCase,
private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val singleAccountSupplier: SingleAccountSupplier,
@Assisted private val mode: AddCustomTokenMode,
) {
suspend fun needColdWalletInteraction(network: Map<String, String?>): Boolean = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = mode.userWalletId,
networksWithDerivationPath = network,
)
}
suspend fun addCryptoCurrenciesUseCase(currency: CryptoCurrency): Either<Throwable, Unit> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> addCryptoCurrenciesUseCase.invoke(
userWalletId = mode.userWalletId,
currency = currency,
)
is AddCustomTokenMode.Account -> {
manageCryptoCurrenciesUseCase(
accountId = AccountId.forCryptoPortfolio(
userWalletId = mode.userWalletId,
derivationIndex = DerivationIndex.Main,
),
add = currency,
)
}
is AddCustomTokenMode.Wallet -> {
addCryptoCurrenciesUseCase.invoke(
userWalletId = mode.userWalletId,
currency = currency,
)
}
}
suspend fun derivePublicKeysUseCase(currencies: List<CryptoCurrency>): Either<Throwable, Unit> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Account -> Unit.right()
is AddCustomTokenMode.Wallet -> derivePublicKeysUseCase.invoke(
userWalletId = mode.userWalletId,
currencies = currencies,
@ -60,7 +58,19 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
derivationPath: Network.DerivationPath,
contractAddress: String?,
): Either<Throwable, Boolean> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Account -> {
val account = singleAccountSupplier.getSyncOrNull(
params = SingleAccountProducer.Params(accountId = mode.accountId),
)
?: return IllegalStateException("Account not found").left()
account.cryptoCurrencies.none { currency ->
networkId == currency.network.id &&
derivationPath == currency.network.derivationPath &&
contractAddress.equals(currency.id.contractAddress, ignoreCase = true)
}
.right()
}
is AddCustomTokenMode.Wallet -> checkIsCurrencyNotAddedUseCase.invoke(
userWalletId = mode.userWalletId,
networkId = networkId,
@ -69,45 +79,6 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
)
}
suspend fun createCryptoCurrencyUseCase(
networkId: Network.ID,
derivationPath: Network.DerivationPath,
formValues: AddCustomTokenForm.Validated.All?,
): Either<Throwable, CryptoCurrency> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> createCryptoCurrencyUseCase.invoke(
userWalletId = mode.userWalletId,
networkId = networkId,
derivationPath = derivationPath,
formValues = formValues,
)
}
suspend fun findTokenUseCase(
contractAddress: String,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): Either<FindTokenException, CryptoCurrency.Token> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> findTokenUseCase.invoke(
userWalletId = mode.userWalletId,
contractAddress = contractAddress,
networkId = networkId,
derivationPath = derivationPath,
)
}
suspend fun validateTokenFormUseCase(
networkId: Network.ID,
formValues: AddCustomTokenForm.Raw,
): Either<NonEmptyList<CustomTokenFormValidationException>, AddCustomTokenForm.Validated> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> validateTokenFormUseCase.invoke(
networkId = networkId,
formValues = formValues,
)
}
@AssistedFactory
interface Factory {
fun create(mode: AddCustomTokenMode): CustomTokenFormUseCasesFacade

View file

@ -90,8 +90,9 @@ internal class ManageTokensListManager @AssistedInject constructor(
*/
suspend fun launchPagination(isCollapsed: Boolean) = coroutineScope {
val loadUserTokensFromRemote = when (mode) {
is ManageTokensMode.Wallet -> source == ManageTokensSource.ONBOARDING
is ManageTokensMode.Wallet,
is ManageTokensMode.Account,
-> source == ManageTokensSource.ONBOARDING
ManageTokensMode.None,
-> false
}

View file

@ -2,10 +2,17 @@ package com.tangem.features.managetokens.utils.list
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.managetokens.*
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.managetokens.model.ManageTokensListConfig
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
@ -23,6 +30,10 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor(
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
private val saveManagedTokensUseCase: SaveManagedTokensUseCase,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val customTokensRepository: CustomTokensRepository,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val singleAccountSupplier: SingleAccountSupplier,
@Assisted private val mode: ManageTokensMode,
) {
@ -30,17 +41,33 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor(
get() = IllegalStateException("Unsupported")
fun manageTokensListConfig(searchText: String?): ManageTokensListConfig {
val userWalletId: UserWalletId? = when (mode) {
is ManageTokensMode.Account -> TODO("Account")
ManageTokensMode.None -> null
is ManageTokensMode.Wallet -> mode.userWalletId
return when (mode) {
is ManageTokensMode.Account -> {
ManageTokensListConfig.Account(accountId = mode.accountId, searchText = searchText)
}
is ManageTokensMode.Wallet -> {
ManageTokensListConfig.Wallet(userWalletId = mode.userWalletId, searchText = searchText)
}
ManageTokensMode.None -> {
if (accountsFeatureToggles.isFeatureEnabled) {
ManageTokensListConfig.Account(accountId = null, searchText = searchText)
} else {
ManageTokensListConfig.Wallet(userWalletId = null, searchText = searchText)
}
}
}
return ManageTokensListConfig(userWalletId, searchText)
}
suspend fun removeCustomCurrencyUseCase(customCurrency: ManagedCryptoCurrency.Custom): Either<Throwable, Unit> {
return when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Account -> {
val currency = customTokensRepository.convertToCryptoCurrency(
userWalletId = mode.accountId.userWalletId,
currency = customCurrency,
)
manageCryptoCurrenciesUseCase(accountId = mode.accountId, remove = currency)
}
is ManageTokensMode.Wallet -> removeCustomCurrencyUseCase.invoke(
userWalletId = mode.userWalletId,
customCurrency = customCurrency,
@ -55,7 +82,21 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor(
tempRemovedTokens: Map<ManagedCryptoCurrency.Token, Set<Network>>,
): Either<Throwable, Boolean> {
return when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Account -> {
val added = tempAddedTokens.mapToCryptoCurrencies(userWalletId = mode.accountId.userWalletId)
val removed = tempRemovedTokens.mapToCryptoCurrencies(userWalletId = mode.accountId.userWalletId)
val account = singleAccountSupplier.getSyncOrNull(
params = SingleAccountProducer.Params(accountId = mode.accountId),
)
?: return IllegalStateException("Account not found").left()
(account.cryptoCurrencies + added - removed).any {
it is CryptoCurrency.Token && it.network.backendId == network.backendId &&
it.network.derivationPath == network.derivationPath
}
.right()
}
is ManageTokensMode.Wallet -> checkHasLinkedTokensUseCase.invoke(
userWalletId = mode.userWalletId,
network = network,
@ -70,7 +111,10 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor(
sourceNetwork: ManagedCryptoCurrency.SourceNetwork,
): Either<Throwable, CurrencyUnsupportedState?> {
return when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Account -> checkCurrencyUnsupportedUseCase.invoke(
userWalletId = mode.accountId.userWalletId,
sourceNetwork = sourceNetwork,
)
is ManageTokensMode.Wallet -> checkCurrencyUnsupportedUseCase.invoke(
userWalletId = mode.userWalletId,
sourceNetwork = sourceNetwork,
@ -80,7 +124,10 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor(
}
suspend fun needColdWalletInteraction(network: Map<String, Nothing?>): Boolean = when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Account -> coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = mode.accountId.userWalletId,
networksWithDerivationPath = network,
)
is ManageTokensMode.Wallet -> coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = mode.userWalletId,
networksWithDerivationPath = network,
@ -92,15 +139,46 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor(
currenciesToAdd: Map<ManagedCryptoCurrency.Token, Set<Network>>,
currenciesToRemove: Map<ManagedCryptoCurrency.Token, Set<Network>>,
): Either<Throwable, Unit> = when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Wallet -> saveManagedTokensUseCase.invoke(
userWalletId = mode.userWalletId,
currenciesToAdd = currenciesToAdd,
currenciesToRemove = currenciesToRemove,
)
is ManageTokensMode.Account -> {
manageCryptoCurrenciesUseCase(
accountId = mode.accountId,
add = currenciesToAdd.mapToCryptoCurrencies(userWalletId = mode.accountId.userWalletId),
remove = currenciesToRemove.mapToCryptoCurrencies(userWalletId = mode.accountId.userWalletId),
)
}
is ManageTokensMode.Wallet -> {
saveManagedTokensUseCase.invoke(
userWalletId = mode.userWalletId,
currenciesToAdd = currenciesToAdd,
currenciesToRemove = currenciesToRemove,
)
}
ManageTokensMode.None -> nonePortfolioError.left()
}
private suspend fun Map<ManagedCryptoCurrency.Token, Set<Network>>.mapToCryptoCurrencies(
userWalletId: UserWalletId,
): List<CryptoCurrency> {
return flatMap { (token, networks) ->
token.availableNetworks
.filter { sourceNetwork -> networks.contains(sourceNetwork.network) }
.map { sourceNetwork ->
when (sourceNetwork) {
is ManagedCryptoCurrency.SourceNetwork.Default -> customTokensRepository.createToken(
managedCryptoCurrency = token,
sourceNetwork = sourceNetwork,
rawId = CryptoCurrency.RawID(token.id.value),
)
is ManagedCryptoCurrency.SourceNetwork.Main -> customTokensRepository.createCoin(
userWalletId = userWalletId,
networkId = sourceNetwork.id,
derivationPath = sourceNetwork.network.derivationPath,
)
}
}
}
}
@AssistedFactory
interface Factory {
fun create(mode: ManageTokensMode): ManageTokensUseCasesFacade

View file

@ -13,6 +13,7 @@ import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.MarketsTokenDetailsComponent.Params
@ -33,6 +34,7 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Params,
analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
portfolioComponentFactory: MarketsPortfolioComponent.Factory,
) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent {
@ -114,6 +116,7 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor(
onBackClick = ::navigateBack,
backButtonEnabled = bsState == BottomSheetState.EXPANDED,
onHeaderSizeChange = onHeaderSizeChange,
isAccountEnabled = accountsFeatureToggles.isFeatureEnabled,
portfolioBlock = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)
@ -141,6 +144,7 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor(
onBackClick = ::navigateBack,
backButtonEnabled = true,
onHeaderSizeChange = {},
isAccountEnabled = accountsFeatureToggles.isFeatureEnabled,
portfolioBlock = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)

View file

@ -58,6 +58,7 @@ internal fun MarketsTokenDetailsContent(
onBackClick: () -> Unit,
onHeaderSizeChange: (Dp) -> Unit,
backButtonEnabled: Boolean,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
@ -69,6 +70,7 @@ internal fun MarketsTokenDetailsContent(
onHeaderSizeChange = onHeaderSizeChange,
backButtonEnabled = backButtonEnabled,
portfolioBlock = portfolioBlock,
isAccountEnabled = isAccountEnabled,
addTopBarStatusBarInsets = addTopBarStatusBarPadding,
)
@ -88,6 +90,7 @@ private fun Content(
onBackClick: () -> Unit,
onHeaderSizeChange: (Dp) -> Unit,
backButtonEnabled: Boolean,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
@ -153,6 +156,7 @@ private fun Content(
tokenMarketDetailsBody(
state = state.body,
isAccountEnabled = isAccountEnabled,
portfolioBlock = portfolioBlock,
)
}
@ -348,6 +352,7 @@ private fun Preview() {
backgroundColor = TangemTheme.colors.background.tertiary,
portfolioBlock = {},
backButtonEnabled = true,
isAccountEnabled = true,
addTopBarStatusBarPadding = false,
)
}

View file

@ -4,17 +4,21 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.items.DescriptionItem
import com.tangem.core.ui.components.items.DescriptionPlaceholder
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
internal fun LazyListScope.tokenMarketDetailsBody(
state: MarketsTokenDetailsUM.Body,
isAccountEnabled: Boolean,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
when (state) {
@ -29,6 +33,10 @@ internal fun LazyListScope.tokenMarketDetailsBody(
}
}
if (isAccountEnabled) {
aboutCoinHeader()
}
loadingInfoBlocks()
}
is MarketsTokenDetailsUM.Body.Content -> {
@ -42,6 +50,10 @@ internal fun LazyListScope.tokenMarketDetailsBody(
}
}
if (isAccountEnabled) {
aboutCoinHeader()
}
infoBlocksList(state.infoBlocks)
}
is MarketsTokenDetailsUM.Body.Error -> {
@ -69,6 +81,21 @@ private fun LazyListScope.error(state: MarketsTokenDetailsUM.Body.Error) {
}
}
private fun LazyListScope.aboutCoinHeader() {
item("aboutCoinHeader") {
Text(
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing20,
),
text = stringResourceSafe(R.string.markets_about_coin_header),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h3,
)
}
}
private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) {
item("description") {
DescriptionItem(

View file

@ -0,0 +1,18 @@
package com.tangem.features.markets.portfolio.add.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
internal interface AddToPortfolioComponent : ComposableBottomSheetComponent {
data class Params(
val addToPortfolioManager: AddToPortfolioManager,
val callback: Callback,
)
interface Callback {
fun onDismiss()
}
interface Factory : ComponentFactory<Params, AddToPortfolioComponent>
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.markets.portfolio.add.api
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent.AnalyticsParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal interface AddToPortfolioManager {
val token: TokenMarketParams
val analyticsParams: AnalyticsParams?
val portfolioFetcher: PortfolioFetcher
val state: StateFlow<State>
val allAvailableNetworks: Flow<List<TokenMarketInfo.Network>>
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>)
sealed interface State {
data object Init : State
data class AvailableToAdd(
val availableToAddData: AvailableToAddData,
) : State
data object NothingToAdd : State
}
interface Factory {
fun create(
scope: CoroutineScope,
token: TokenMarketParams,
analyticsParams: AnalyticsParams?,
): AddToPortfolioManager
}
}

View file

@ -7,6 +7,7 @@ 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
import kotlinx.serialization.Serializable
internal data class AvailableToAddData(
val availableToAddWallets: Map<UserWalletId, AvailableToAddWallet>,
@ -19,11 +20,12 @@ internal data class AvailableToAddData(
internal data class AvailableToAddWallet(
val userWallet: UserWallet,
val accounts: Set<AccountStatus>,
val accounts: List<AccountStatus>,
val availableNetworks: Set<TokenMarketInfo.Network>,
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount>,
)
@Serializable
internal data class AvailableToAddAccount(
val account: AccountStatus,
val availableNetworks: Set<TokenMarketInfo.Network>,
@ -41,6 +43,7 @@ internal data class AvailableToAddAccount(
.toSet()
}
@Serializable
internal data class SelectedPortfolio(
val userWallet: UserWallet,
val account: AvailableToAddAccount,

View file

@ -8,7 +8,6 @@ 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
@ -38,7 +37,6 @@ internal class AddTokenComponent @AssistedInject constructor(
}
data class Params(
val marketParams: TokenMarketParams,
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val selectedPortfolio: Flow<SelectedPortfolio>,
val selectedNetwork: Flow<SelectedNetwork>,

View file

@ -1,48 +1,36 @@
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.TokenMarketInfo
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import com.tangem.features.markets.portfolio.add.impl.model.ChooseNetworkModel
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)
},
)
}
private val model: ChooseNetworkModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ChooseNetworkContent(state)
}
data class Params(
val alreadyAdded: Set<TokenMarketInfo.Network>,
val allAvailable: List<TokenMarketInfo.Network>,
val selectedPortfolio: SelectedPortfolio,
val callbacks: Callbacks,
)

View file

@ -0,0 +1,210 @@
package com.tangem.features.markets.portfolio.add.impl
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.backStack
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent.Params
import com.tangem.features.markets.portfolio.add.impl.model.AddToPortfolioModel
import com.tangem.features.markets.portfolio.add.impl.model.AddToPortfolioRoutes
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
addTokenComponentFactory: AddTokenComponent.Factory,
tokenActionsComponentFactory: TokenActionsComponent.Factory,
private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory,
) : AppComponentContext by context, AddToPortfolioComponent {
private val model: AddToPortfolioModel = getOrCreateModel(params)
private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create(
context = child("portfolioSelectorComponent"),
params = PortfolioSelectorComponent.Params(
portfolioFetcher = model.portfolioFetcher,
controller = model.portfolioSelectorController,
),
)
private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create(
context = child("addTokenComponent"),
params = AddTokenComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
selectedPortfolio = model.selectedPortfolio,
selectedNetwork = model.selectedNetwork,
),
)
private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create(
context = child("tokenActionsComponent"),
params = TokenActionsComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
data = model.tokenActionsData,
),
)
private val childStack = childStack(
key = "addToPortfolioStack",
handleBackButton = true,
source = model.navigation,
serializer = AddToPortfolioRoutes.serializer(),
initialStack = { model.currentStack },
childFactory = ::contentChild,
)
private fun onBack() {
if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss()
}
override fun dismiss() {
params.callback.onDismiss()
}
@Composable
override fun BottomSheet() {
val stack by childStack.subscribeAsState()
val contentStack = remember { mutableStateOf(stack) }
val currentRoute = stack.active.configuration
val isNotEmpty = currentRoute != AddToPortfolioRoutes.Empty
if (isNotEmpty) {
contentStack.value = stack
}
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
scrollableContent = false,
onBack = ::onBack,
config = TangemBottomSheetConfig(
isShown = isNotEmpty,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors.background.tertiary,
title = { state ->
AnimatedContent(targetState = contentStack.value) { stack ->
BottomSheetTitle(
stack = stack,
onBackClick = ::onBack,
modifier = Modifier.fillMaxWidth(),
)
}
},
content = { state ->
AnimatedContent(targetState = contentStack.value) { stack ->
val paddingModifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
)
val scrollableContent = when (stack.active.configuration) {
AddToPortfolioRoutes.PortfolioSelector -> false
AddToPortfolioRoutes.AddToken,
AddToPortfolioRoutes.Empty,
is AddToPortfolioRoutes.NetworkSelector,
AddToPortfolioRoutes.TokenActions,
-> true
}
if (scrollableContent) {
Column(
modifier = paddingModifier.verticalScroll(rememberScrollState()),
) {
stack.active.instance.Content(modifier = Modifier)
}
} else {
stack.active.instance.Content(modifier = paddingModifier)
}
}
},
)
}
@Composable
private fun BottomSheetTitle(
stack: ChildStack<AddToPortfolioRoutes, ComposableContentComponent>,
onBackClick: (() -> Unit),
modifier: Modifier = Modifier,
) {
val title: TextReference = when (stack.active.configuration) {
AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token)
AddToPortfolioRoutes.Empty -> TextReference.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network)
AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token)
AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent)
.title.collectAsStateWithLifecycle().value
}
val startIconRes: Int?
val endIconRes: Int?
if (stack.backStack.isNotEmpty()) {
startIconRes = R.drawable.ic_back_24
endIconRes = null
} else {
startIconRes = null
endIconRes = R.drawable.ic_close_24
}
TangemModalBottomSheetTitle(
modifier = modifier,
title = title,
startIconRes = startIconRes,
endIconRes = endIconRes,
onStartClick = onBackClick,
onEndClick = onBackClick,
)
}
private fun contentChild(
config: AddToPortfolioRoutes,
componentContext: ComponentContext,
): ComposableContentComponent = when (config) {
AddToPortfolioRoutes.AddToken -> addTokenComponent
AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent
AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create(
context = childByContext(componentContext),
params = ChooseNetworkComponent.Params(
selectedPortfolio = config.selectedPortfolio,
callbacks = model,
),
)
}
@AssistedFactory
interface Factory : AddToPortfolioComponent.Factory {
override fun create(context: AppComponentContext, params: Params): DefaultAddToPortfolioComponent
}
}

View file

@ -5,6 +5,7 @@ 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.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
@ -28,11 +29,19 @@ internal class AvailableToAddDataConverter @Inject constructor(
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()
val currencies = availableNetworks
.mapNotNull { createCryptoCurrency(wallet, it, marketParams, this.account) }
val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, currencies)
.fold(
ifEmpty = { emptySet() },
ifSome = { map ->
map.values.flatMapTo(hashSetOf()) { statuses ->
statuses.map { it.currency.network }
}
},
)
return AvailableToAddAccount(
account = this,
availableNetworks = availableNetworks,
@ -60,7 +69,7 @@ internal class AvailableToAddDataConverter @Inject constructor(
val availableToAddWallets: Map<UserWalletId, AvailableToAddWallet> = balances
.map {
val (wallet, balance) = it
val (wallet, _) = it
val availableToAddWallet = getAvailableToAddWallet(it)
wallet.walletId to availableToAddWallet
}
@ -82,9 +91,16 @@ internal class AvailableToAddDataConverter @Inject constructor(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
marketParams: TokenMarketParams,
): CryptoCurrency? = getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = marketParams,
network = network,
)
account: Account,
): CryptoCurrency? {
val derivationIndex = when (account) {
is Account.CryptoPortfolio -> account.derivationIndex
}
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = marketParams,
network = network,
accountIndex = derivationIndex,
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.markets.portfolio.add.impl.di
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager
import com.tangem.features.markets.portfolio.add.impl.DefaultAddToPortfolioComponent
import com.tangem.features.markets.portfolio.add.impl.ui.DefaultAddToPortfolioManager
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface AddToPortfolioComponentModule {
@Binds
fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory
@Binds
fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.markets.portfolio.add.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.markets.portfolio.add.impl.model.AddToPortfolioModel
import com.tangem.features.markets.portfolio.add.impl.model.AddTokenModel
import com.tangem.features.markets.portfolio.add.impl.model.ChooseNetworkModel
import com.tangem.features.markets.portfolio.add.impl.model.TokenActionsModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface AddToPortfolioModelModule {
@Binds
@IntoMap
@ClassKey(AddTokenModel::class)
fun addTokenModel(model: AddTokenModel): Model
@Binds
@IntoMap
@ClassKey(AddToPortfolioModel::class)
fun addToPortfolioModel(model: AddToPortfolioModel): Model
@Binds
@IntoMap
@ClassKey(TokenActionsModel::class)
fun tokenActionsModel(model: TokenActionsModel): Model
@Binds
@IntoMap
@ClassKey(ChooseNetworkModel::class)
fun chooseNetworkModel(model: ChooseNetworkModel): Model
}

View file

@ -0,0 +1,358 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.popToFirst
import com.arkivanov.decompose.router.stack.pushNew
import com.arkivanov.decompose.router.stack.replaceAll
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.resourceReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
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.wallet.UserWallet
import com.tangem.features.account.PortfolioSelectorController
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.api.*
import com.tangem.features.markets.portfolio.add.impl.AddTokenComponent
import com.tangem.features.markets.portfolio.add.impl.ChooseNetworkComponent
import com.tangem.features.markets.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val TOKEN_ACTIONS_DELAY = 500L
@ModelScoped
@Suppress("LongParameterList")
internal class AddToPortfolioModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val callbackDelegate: AddToPortfolioCallbackDelegate,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val messageSender: UiMessageSender,
val portfolioSelectorController: PortfolioSelectorController,
) : Model(),
ChooseNetworkComponent.Callbacks by callbackDelegate,
TokenActionsComponent.Callbacks by callbackDelegate,
AddTokenComponent.Callbacks by callbackDelegate {
private val params = paramsContainer.require<AddToPortfolioComponent.Params>()
val navigation = StackNavigation<AddToPortfolioRoutes>()
var currentStack = listOf<AddToPortfolioRoutes>(AddToPortfolioRoutes.Empty)
/* Flows that hold state and provide it to child models */
val selectedNetwork: MutableSharedFlow<SelectedNetwork> = replayMutableSharedFlow()
val selectedPortfolio: MutableSharedFlow<SelectedPortfolio> = replayMutableSharedFlow()
val tokenActionsData: MutableSharedFlow<PortfolioData.CryptoCurrencyData> = replayMutableSharedFlow()
private val addToPortfolioManager = params.addToPortfolioManager
val portfolioFetcher = addToPortfolioManager.portfolioFetcher
val eventBuilder = PortfolioAnalyticsEvent.EventBuilder(
token = addToPortfolioManager.token,
source = addToPortfolioManager.analyticsParams?.source,
)
val featureData: Flow<AddToPortfolioManager.State> = combineFeatureData()
init {
navigation.subscribe { currentStack = it.transformer.invoke(currentStack) }
startAddToPortfolioFlow()
}
private fun <T> replayMutableSharedFlow() = MutableSharedFlow<T>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@Suppress("LongMethod")
private fun startAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishFlow() {
params.callback.onDismiss()
channel.close()
}
val featureDataFlow: StateFlow<AvailableToAddData> = featureData
.filterIsInstance<AddToPortfolioManager.State.AvailableToAdd>()
.map { it.availableToAddData }
.distinctUntilChanged()
.stateIn(this)
// use snapshot data, looks like we dont need to remap at runtime
val data = featureDataFlow.value
// you must control it via [AddToPortfolioManager.state]
if (!data.availableToAdd) {
finishFlow()
return@channelFlow
}
// init data flows, emits on user/code selection, updates state holder
val firstSelectedPortfolio = setupPortfolioFlow(data)
.onEach { selectedPortfolio.emit(it) }
val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio)
.onEach { selectedNetwork.emit(it) }
val isSinglePortfolio = data.isSinglePortfolio
if (isSinglePortfolio) {
val accountId = data.availableToAddWallets.values.first()
.availableToAddAccounts.values.first()
.account.account.accountId
// force select a portfolio, triggers [selectedPortfolio]
portfolioSelectorController.selectAccount(accountId)
} else {
navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector)
}
val firstPartOfNavigation: Job = firstSelectedPortfolio
.onEach { portfolio ->
val isSingleAvailableNetwork = portfolio.account.isSingleNetwork
when {
// force select a network, triggers [selectedNetwork]
isSingleAvailableNetwork -> {
val singleNetwork = portfolio.account.availableToAddNetworks.first()
callbackDelegate.onNetworkSelected(singleNetwork)
}
// it's important to control root screen, UI depends on it(close/arrow icon)
isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio))
else -> navigation.pushNew(routeToNetworkSelector(portfolio))
}
}
.launchIn(this)
// main flow that combine all require data
val allRequireForAdd = combine(
flow = firstSelectedNetwork,
flow2 = firstSelectedPortfolio,
transform = { a, b -> a to b },
)
// suspend until all required data is selected
allRequireForAdd.first()
// line of navigation to AddToken screen is finished; cancel the job, select a new root screen
firstPartOfNavigation.cancel()
navigation.replaceAll(AddToPortfolioRoutes.AddToken)
var middleNavigationJob: Job? = null
// handle actions from AddToken screen
callbackDelegate.onChangeNetworkClick.receiveAsFlow()
.onEach {
middleNavigationJob?.cancel()
middleNavigationJob = changeNetworkNavigationFlow()
.launchIn(this)
val route = routeToNetworkSelector(selectedPortfolio.first())
navigation.pushNew(route)
}
.launchIn(this)
// handle actions from AddToken screen
callbackDelegate.onChangePortfolioClick.receiveAsFlow()
.onEach {
middleNavigationJob?.cancel()
middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this)
navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector)
}
.launchIn(this)
// suspend until token is added
val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first()
middleNavigationJob?.cancel()
val selectedPortfolio = selectedPortfolio.first()
messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added)))
setupTokenActionsFlow(selectedPortfolio, addedToken)
.onEach {
tokenActionsData.emit(it)
navigation.replaceAll(AddToPortfolioRoutes.TokenActions)
}
.onEmpty { finishFlow() }
.launchIn(this)
callbackDelegate.onLaterClick.receiveAsFlow().first()
finishFlow()
}
.catch {
Timber.e(it)
params.callback.onDismiss()
}
.launchIn(modelScope)
}
private fun changeNetworkNavigationFlow(): Flow<SelectedNetwork> {
return setupNetworkFlow(selectedPortfolio)
.onEach { newNetwork ->
selectedNetwork.emit(newNetwork)
navigation.popToFirst()
}
}
private suspend fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow<Unit> {
val selectedAccount = selectedPortfolio.first().account.account.account.accountId
portfolioSelectorController.selectAccount(selectedAccount)
val changedPortfolio = setupPortfolioFlow(data)
.drop(1)
.onEach { portfolio -> navigation.pushNew(routeToNetworkSelector(portfolio)) }
val changedNetwork = setupNetworkFlow(changedPortfolio)
return combine(
flow = changedPortfolio,
flow2 = changedNetwork,
transform = { newPortfolio, newNetwork ->
selectedPortfolio.tryEmit(newPortfolio)
selectedNetwork.tryEmit(newNetwork)
navigation.popToFirst()
},
)
}
private fun setupTokenActionsFlow(
selectedPortfolio: SelectedPortfolio,
addedToken: CryptoCurrencyStatus,
): Flow<PortfolioData.CryptoCurrencyData> {
val timeFlow = channelFlow {
val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) }
getCryptoCurrencyActionsUseCase(
currency = addedToken.currency,
accountId = selectedPortfolio.account.account.account.accountId,
).onEach { state ->
val requestedQuickActions = toQuickActions(state.states)
when {
requestedQuickActions.isNotEmpty() -> {
timerJob.cancel()
send(state)
}
// wait any requestedQuickActions while timer active
timerJob.isActive -> Unit
else -> close()
}
}.collect()
}
return timeFlow.map {
PortfolioData.CryptoCurrencyData(
userWallet = selectedPortfolio.userWallet,
status = addedToken,
actions = it.states,
)
}
}
private fun setupPortfolioFlow(data: AvailableToAddData): Flow<SelectedPortfolio> = combine(
flow = portfolioSelectorController.isAccountMode,
flow2 = portfolioSelectorController.selectedAccount,
transform = { isAccountMode, selectedAccountId ->
selectedAccountId ?: return@combine null
val availableToAddWallets =
data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null
val availableToAddAccount =
availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null
SelectedPortfolio(
isAccountMode = isAccountMode,
userWallet = availableToAddWallets.userWallet,
account = availableToAddAccount,
availableMorePortfolio = !data.isSinglePortfolio,
)
},
)
.filterNotNull()
private fun setupNetworkFlow(selectedPortfolioFlow: Flow<SelectedPortfolio>): Flow<SelectedNetwork> = combine(
flow = selectedPortfolioFlow,
flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(),
transform = transform@{ selectedPortfolio, selectedNetwork ->
SelectedNetwork(
cryptoCurrency = createCryptoCurrency(
userWallet = selectedPortfolio.userWallet,
network = selectedNetwork,
account = selectedPortfolio.account,
) ?: return@transform null,
selectedNetwork = selectedNetwork,
availableMoreNetwork = !selectedPortfolio.account.isSingleNetwork,
)
},
)
.filterNotNull()
private suspend fun createCryptoCurrency(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
account: AvailableToAddAccount,
): CryptoCurrency? {
val accountIndex = when (account.account) {
is AccountStatus.CryptoPortfolio -> account.account.account.derivationIndex
}
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = addToPortfolioManager.token,
network = network,
accountIndex = accountIndex,
)
}
private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector {
return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio)
}
private fun combineFeatureData() = addToPortfolioManager.state.onEach { state ->
when (state) {
is AddToPortfolioManager.State.AvailableToAdd ->
portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus ->
val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId]
?: return@isEnabled false
val availableAccount =
availableWallet.availableToAddAccounts[accountStatus.account.accountId]
return@isEnabled availableAccount != null
}
AddToPortfolioManager.State.Init,
AddToPortfolioManager.State.NothingToAdd,
-> Unit
}
}
}
@ModelScoped
internal class AddToPortfolioCallbackDelegate @Inject constructor() :
ChooseNetworkComponent.Callbacks,
TokenActionsComponent.Callbacks,
AddTokenComponent.Callbacks {
val onNetworkSelected = Channel<TokenMarketInfo.Network>()
val onLaterClick = Channel<Unit>()
val onChangeNetworkClick = Channel<Unit>()
val onChangePortfolioClick = Channel<Unit>()
val onTokenAdded = Channel<CryptoCurrencyStatus>()
override fun onNetworkSelected(network: TokenMarketInfo.Network) {
onNetworkSelected.trySend(network)
}
override fun onLaterClick() {
onLaterClick.trySend(Unit)
}
override fun onChangeNetworkClick() {
onChangeNetworkClick.trySend(Unit)
}
override fun onChangePortfolioClick() {
onChangePortfolioClick.trySend(Unit)
}
override fun onTokenAdded(status: CryptoCurrencyStatus) {
onTokenAdded.trySend(status)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.markets.portfolio.add.impl.model
import androidx.compose.runtime.Immutable
import com.tangem.core.decompose.navigation.Route
import com.tangem.features.markets.portfolio.add.api.SelectedPortfolio
import kotlinx.serialization.Serializable
@Serializable
@Immutable
internal sealed interface AddToPortfolioRoutes : Route {
@Serializable
data object Empty : AddToPortfolioRoutes
@Serializable
data object PortfolioSelector : AddToPortfolioRoutes
@Serializable
data class NetworkSelector(
val selectedPortfolio: SelectedPortfolio,
) : AddToPortfolioRoutes
@Serializable
data object AddToken : AddToPortfolioRoutes
@Serializable
data object TokenActions : AddToPortfolioRoutes
}

View file

@ -4,9 +4,14 @@ 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.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.SaveCryptoCurrenciesUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
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
@ -25,10 +30,11 @@ import javax.inject.Inject
internal class AddTokenModel @Inject constructor(
paramsContainer: ParamsContainer,
private val uiBuilder: AddTokenUiBuilder,
private val messageSender: UiMessageSender,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val saveCryptoCurrenciesUseCase: SaveCryptoCurrenciesUseCase,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
) : Model() {
@ -66,19 +72,26 @@ internal class AddTokenModel @Inject constructor(
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(),
)
manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency)
.onLeft {
processError(error = it)
uiState.value = um.toggleProgress(false)
return@launch
}
val status = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
).getOrNull() ?: return@launch
params.callbacks.onTokenAdded(status.status)
).getOrNull()
if (status == null) {
processError(error = null)
} else {
params.callbacks.onTokenAdded(status.status)
}
uiState.value = um.toggleProgress(false)
}
@ -89,4 +102,10 @@ internal class AddTokenModel @Inject constructor(
userWalletId = selectedPortfolio.userWallet.walletId,
networksWithDerivationPath = mapOf(selectedNetwork.selectedNetwork.networkId to null),
)
private fun processError(error: Throwable?) {
val message = error?.message?.let { stringReference(it) }
?: resourceReference(R.string.common_something_went_wrong)
messageSender.send(ToastMessage(message = message))
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.markets.portfolio.add.impl.model
import com.tangem.common.ui.account.CryptoPortfolioIconUM
import com.tangem.common.ui.account.PortfolioSelectUM
import com.tangem.common.ui.account.toUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
@ -33,7 +34,7 @@ internal class AddTokenUiBuilder @Inject constructor(
)
}
private fun createPortfolio(selectedPortfolio: SelectedPortfolio): AddTokenUM.Portfolio {
private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM {
val accountIcon: CryptoPortfolioIconUM?
val portfolioName: TextReference
when (selectedPortfolio.isAccountMode) {
@ -49,10 +50,11 @@ internal class AddTokenUiBuilder @Inject constructor(
}
}
}
return AddTokenUM.Portfolio(
accountIconUM = accountIcon,
return PortfolioSelectUM(
icon = accountIcon,
name = portfolioName,
editable = selectedPortfolio.availableMorePortfolio,
isAccountMode = selectedPortfolio.isAccountMode,
isMultiChoice = selectedPortfolio.availableMorePortfolio,
onClick = { params.callbacks.onChangePortfolioClick() },
)
}

View file

@ -0,0 +1,125 @@
package com.tangem.features.markets.portfolio.add.impl.model
import arrow.core.getOrElse
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.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.add.impl.ChooseNetworkComponent
import com.tangem.features.markets.portfolio.add.impl.ui.state.ChooseNetworkUM
import com.tangem.features.markets.portfolio.impl.model.BlockchainRowUMConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class ChooseNetworkModel @Inject constructor(
paramsContainer: ParamsContainer,
private val messageSender: UiMessageSender,
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<ChooseNetworkComponent.Params>()
val uiState: StateFlow<ChooseNetworkUM> = MutableStateFlow(buildUI())
private fun buildUI(): ChooseNetworkUM {
val allAvailable = params.selectedPortfolio.account.availableNetworks
val alreadyAdded = allAvailable
.subtract(params.selectedPortfolio.account.availableToAddNetworks)
val converter = BlockchainRowUMConverter(
alreadyAddedNetworks = alreadyAdded.mapTo(mutableSetOf()) { it.networkId },
)
val allAvailableNetworks = allAvailable.map { it to true }
return ChooseNetworkUM(
networks = converter.convertList(allAvailableNetworks).toPersistentList(),
onNetworkClick = onNetworkClick@{ row ->
val network = allAvailable
.find { it.networkId == row.id }
?: return@onNetworkClick
checkNetwork(row, network)
},
)
}
private fun checkNetwork(row: BlockchainRowUM, network: TokenMarketInfo.Network) = modelScope.launch {
val selectedWalletId = params.selectedPortfolio.userWallet.walletId
val unsupportedState = checkCurrencyUnsupportedState(
userWalletId = selectedWalletId,
rawNetworkId = row.id,
isMainNetwork = row.isMainNetwork,
)
if (unsupportedState != null) {
showUnsupportedWarning(unsupportedState)
} else {
params.callbacks.onNetworkSelected(network)
}
}
private suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
rawNetworkId: String,
isMainNetwork: Boolean,
): CurrencyUnsupportedState? {
return checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
networkId = rawNetworkId,
isMainNetwork = isMainNetwork,
).getOrElse {
Timber.e(
it,
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Network ID: $rawNetworkId
|- Is main network: $isMainNetwork
""".trimIndent(),
)
val message = SnackbarMessage(
message = it.localizedMessage?.let(::stringReference) ?: resourceReference(R.string.common_error),
)
messageSender.send(message)
null
}
}
private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) {
val message = DialogMessage(
message = when (unsupportedState) {
is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},
)
messageSender.send(message)
}
}

View file

@ -22,14 +22,14 @@ 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.PortfolioSelectRow
import com.tangem.common.ui.account.PortfolioSelectUM
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
@ -63,12 +63,11 @@ internal fun AddTokenContent(state: AddTokenUM, modifier: Modifier = Modifier) {
SpacerH(TangemTheme.dimens.spacing14)
Column(
modifier = Modifier.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(TangemTheme.dimens.radius14),
),
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action),
) {
PortfolioRow(state.portfolio)
PortfolioSelectRow(state.portfolio)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp),
thickness = TangemTheme.dimens.size0_5,
@ -86,50 +85,6 @@ internal fun AddTokenContent(state: AddTokenUM, modifier: Modifier = Modifier) {
}
}
@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(
@ -240,17 +195,19 @@ private class PreviewProvider : PreviewParameterProvider<AddTokenUM> {
)
val account
get() = AddTokenUM.Portfolio(
accountIconUM = AccountIconPreviewData.randomAccountIcon(),
get() = PortfolioSelectUM(
icon = AccountIconPreviewData.randomAccountIcon(),
name = AccountName.DefaultMain.toUM().value,
editable = true,
isAccountMode = true,
isMultiChoice = true,
onClick = {},
)
val wallet
get() = AddTokenUM.Portfolio(
accountIconUM = null,
name = stringReference("Wallet"),
editable = true,
get() = PortfolioSelectUM(
icon = null,
name = stringReference("Wallet Name"),
isMultiChoice = false,
isAccountMode = false,
onClick = {},
)
@ -271,7 +228,7 @@ private class PreviewProvider : PreviewParameterProvider<AddTokenUM> {
AddTokenUM(
tokenToAdd = tokenState,
network = networkUM.copy(editable = false),
portfolio = wallet.copy(editable = false),
portfolio = wallet.copy(isMultiChoice = false),
button = button.copy(
isEnabled = true,
isTangemIconVisible = true,

View file

@ -0,0 +1,73 @@
package com.tangem.features.markets.portfolio.add.impl.ui
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager.State
import com.tangem.features.markets.portfolio.add.impl.converter.AvailableToAddDataConverter
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
internal class DefaultAddToPortfolioManager @AssistedInject constructor(
private val availableToAddDataConverter: AvailableToAddDataConverter,
@Assisted override val token: TokenMarketParams,
@Assisted override val analyticsParams: MarketsPortfolioComponent.AnalyticsParams?,
@Assisted val scope: CoroutineScope,
dispatchers: CoroutineDispatcherProvider,
portfolioFetcherFactory: PortfolioFetcher.Factory,
) : AddToPortfolioManager {
private val _allAvailableNetworks = MutableSharedFlow<List<TokenMarketInfo.Network>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val allAvailableNetworks: Flow<List<TokenMarketInfo.Network>> = _allAvailableNetworks.asSharedFlow()
override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(onlyMultiCurrency = true),
scope = scope,
)
override val state: StateFlow<State> =
combine(
flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(),
flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(),
) { balances, availableNetworks ->
val data = availableToAddDataConverter.convert(
balances = balances,
availableNetworks = availableNetworks,
marketParams = token,
)
if (data.availableToAdd) {
State.AvailableToAdd(data)
} else {
State.NothingToAdd
}
}
.flowOn(dispatchers.default)
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = State.Init,
)
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
_allAvailableNetworks.tryEmit(networks)
}
@AssistedFactory
interface Factory : AddToPortfolioManager.Factory {
override fun create(
scope: CoroutineScope,
token: TokenMarketParams,
analyticsParams: MarketsPortfolioComponent.AnalyticsParams?,
): DefaultAddToPortfolioManager
}
}

View file

@ -1,24 +1,16 @@
package com.tangem.features.markets.portfolio.add.impl.ui.state
import androidx.annotation.DrawableRes
import com.tangem.common.ui.account.CryptoPortfolioIconUM
import com.tangem.common.ui.account.PortfolioSelectUM
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 portfolio: PortfolioSelectUM,
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,

View file

@ -14,9 +14,10 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioModel
import com.tangem.features.markets.portfolio.impl.model.MarketsPortfolioRoute
import com.tangem.features.markets.portfolio.impl.ui.MyPortfolio
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
@ -28,19 +29,25 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: MarketsPortfolioComponent.Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
) : AppComponentContext by context, MarketsPortfolioComponent {
private val model: MarketsPortfolioModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TokenReceiveConfig.serializer(),
serializer = MarketsPortfolioRoute.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) = model.setTokenNetworks(networks)
override fun setNoNetworksAvailable() = model.setNoNetworksAvailable()
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
model.setTokenNetworks(networks)
}
override fun setNoNetworksAvailable() {
model.setNoNetworksAvailable()
}
@Composable
override fun Content(modifier: Modifier) {
@ -52,15 +59,24 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor(
}
private fun bottomSheetChild(
config: TokenReceiveConfig,
config: MarketsPortfolioRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
): ComposableBottomSheetComponent = when (config) {
MarketsPortfolioRoute.AddToPortfolio -> addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.newAddToPortfolioManager!!,
callback = model.addToPortfolioCallback,
),
)
is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config.config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory
interface Factory : MarketsPortfolioComponent.Factory {

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
@ -16,15 +17,14 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
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.TokenReceiveConfig
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
import com.tangem.domain.models.wallet.isMultiCurrency
@ -32,11 +32,13 @@ 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
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.markets.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState
import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle
import com.tangem.features.wallet.utils.UserWalletImageFetcher
@ -49,6 +51,7 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import com.tangem.features.markets.portfolio.add.api.AddToPortfolioManager as NewAddToPortfolioManager
@Suppress("LongParameterList", "LargeClass")
@Stable
@ -69,6 +72,9 @@ internal class MarketsPortfolioModel @Inject constructor(
private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle,
private val userWalletImageFetcher: UserWalletImageFetcher,
private val receiveAddressesFactory: ReceiveAddressesFactory,
private val accountsFeatureToggles: AccountsFeatureToggles,
newAddToPortfolioManagerFactory: NewAddToPortfolioManager.Factory,
private val newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory,
) : Model() {
val state: StateFlow<MyPortfolioUM> get() = _state
@ -80,12 +86,40 @@ internal class MarketsPortfolioModel @Inject constructor(
source = params.analyticsParams?.source,
)
val newAddToPortfolioManager: NewAddToPortfolioManager?
val newMarketsPortfolioDelegate: NewMarketsPortfolioDelegate?
/** Multi-wallet [UserWalletId] that user uses to add new tokens in AddToPortfolio bottom sheet */
private val selectedMultiWalletIdFlow = MutableStateFlow<UserWalletId?>(value = null)
private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel())
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
val bottomSheetNavigation: SlotNavigation<MarketsPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
}
private val tokenActionsHandler = tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
updateTokenReceiveBSConfig = { updateBlock ->
if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled.not()) {
updateTokensState {
it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig))
}
}
},
onHandleQuickAction = { handledAction ->
analyticsEventHandler.send(
analyticsEventBuilder.quickActionClick(
actionUM = handledAction.action,
blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name,
),
)
if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) {
configureReceiveAddresses(handledAction)
}
},
)
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
@ -132,27 +166,7 @@ internal class MarketsPortfolioModel @Inject constructor(
},
),
currentState = Provider { _state.value },
tokenActionsHandler = tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
updateTokenReceiveBSConfig = { updateBlock ->
if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled.not()) {
updateTokensState {
it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig))
}
}
},
onHandleQuickAction = { handledAction ->
analyticsEventHandler.send(
analyticsEventBuilder.quickActionClick(
actionUM = handledAction.action,
blockchainName = handledAction.cryptoCurrencyData.status.currency.network.name,
),
)
if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) {
configureReceiveAddresses(handledAction)
}
},
),
tokenActionsHandler = tokenActionsHandler,
updateTokens = { updateBlock ->
updateTokensState { state ->
state.copy(tokens = updateBlock(state.tokens))
@ -161,18 +175,50 @@ internal class MarketsPortfolioModel @Inject constructor(
)
init {
// Subscribe on selected wallet flow to support actual selected wallet
subscribeOnSelectedMultiWalletUpdates()
if (accountsFeatureToggles.isFeatureEnabled) {
newAddToPortfolioManager = newAddToPortfolioManagerFactory
.create(
modelScope,
params.token,
params.analyticsParams,
)
newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create(
scope = modelScope,
token = params.token,
tokenActionsHandler = tokenActionsHandler,
buttonState = newAddToPortfolioManager.state.map {
when (it) {
is NewAddToPortfolioManager.State.AvailableToAdd -> AddButtonState.Available
NewAddToPortfolioManager.State.Init -> AddButtonState.Loading
NewAddToPortfolioManager.State.NothingToAdd -> AddButtonState.Unavailable
}
},
onAddClick = { bottomSheetNavigation.activate(MarketsPortfolioRoute.AddToPortfolio) },
)
newMarketsPortfolioDelegate.combineData()
.onEach { _state.value = it }
.flowOn(dispatchers.default)
.launchIn(modelScope)
} else {
newAddToPortfolioManager = null
newMarketsPortfolioDelegate = null
// Subscribe on selected wallet flow to support actual selected wallet
subscribeOnSelectedMultiWalletUpdates()
subscribeOnStateUpdates()
subscribeOnStateUpdates()
}
}
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
addToPortfolioManager.setAvailableNetworks(networks)
newAddToPortfolioManager?.setTokenNetworks(networks)
newMarketsPortfolioDelegate?.setTokenNetworks(networks)
}
fun setNoNetworksAvailable() {
addToPortfolioManager.setAvailableNetworks(emptyList())
newAddToPortfolioManager?.setTokenNetworks(emptyList())
newMarketsPortfolioDelegate?.setTokenNetworks(emptyList())
}
private fun subscribeOnSelectedMultiWalletUpdates() {
@ -378,7 +424,7 @@ internal class MarketsPortfolioModel @Inject constructor(
status = quickAction.cryptoCurrencyData.status,
userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId,
) ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
bottomSheetNavigation.activate(MarketsPortfolioRoute.TokenReceive(tokenConfig))
}
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.models.TokenReceiveConfig
import kotlinx.serialization.Serializable
@Serializable
sealed interface MarketsPortfolioRoute : Route {
@Serializable
data object AddToPortfolio : MarketsPortfolioRoute
@Serializable
data class TokenReceive(
val config: TokenReceiveConfig,
) : MarketsPortfolioRoute
}

View file

@ -0,0 +1,334 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
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
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
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.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioHeader
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioListItem
import com.tangem.features.markets.portfolio.impl.ui.state.WalletHeader
import com.tangem.utils.extensions.isZero
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongParameterList")
internal class NewMarketsPortfolioDelegate @AssistedInject constructor(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val allAccountSupplier: MultiAccountStatusListSupplier,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val getUserWalletUseCase: GetUserWalletUseCase,
isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
@Assisted private val scope: CoroutineScope,
@Assisted private val token: TokenMarketParams,
@Assisted private val tokenActionsHandler: TokenActionsHandler,
@Assisted private val buttonState: Flow<AddButtonState>,
@Assisted private val onAddClick: () -> Unit,
) {
private val currencyRawId: CryptoCurrency.RawID = token.id
private var expandedHolder: MutableStateFlow<Set<Pair<UserWalletId, CryptoCurrency.ID>>>? = null
private val settingsFlow: Flow<SettingsBox> = combine(
flow = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
flow3 = isAccountsModeEnabledUseCase(),
transform = ::SettingsBox,
).shareIn(
replay = 1,
started = SharingStarted.Eagerly,
scope = scope,
).distinctUntilChanged()
private val availableNetworks = MutableSharedFlow<List<TokenMarketInfo.Network>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
availableNetworks.tryEmit(networks)
}
fun combineData(): Flow<MyPortfolioUM> {
return availableNetworks.transformLatest { availableNetworks ->
when {
availableNetworks.isEmpty() -> emit(MyPortfolioUM.Unavailable)
else -> emitAll(onAvailableNetworksFlow().distinctUntilChanged())
}
}.distinctUntilChanged()
}
private fun onAvailableNetworksFlow(): Flow<MyPortfolioUM> =
portfolioWithThisCurrencyFLow().transformLatest { portfolioWithCurrency ->
when (portfolioWithCurrency.flattenAddedCurrency.isEmpty()) {
false -> emitAll(contentFlow(portfolioWithCurrency).distinctUntilChanged())
true -> when (portfolioWithCurrency.hasMultiWallets) {
true -> emitAll(addFirstTokenFlow())
false -> emit(MyPortfolioUM.UnavailableForWallet)
}
}
}
private fun addFirstTokenFlow(): Flow<MyPortfolioUM> = buttonState.map {
when (it) {
AddButtonState.Loading -> MyPortfolioUM.Loading
AddButtonState.Available -> MyPortfolioUM.AddFirstToken(
onAddClick = onAddClick,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
)
AddButtonState.Unavailable -> MyPortfolioUM.Unavailable
}
}
private fun contentFlow(portfolio: PortfoliosWithThisCurrency): Flow<MyPortfolioUM.Content> {
fun Portfolio.actionsFoAccountCurrencies(): List<Flow<Pair<CryptoCurrency, TokenActionsState>>> =
accountsWithAdded.map { account ->
fun CryptoCurrencyStatus.actionsFlow() = getCryptoCurrencyActionsUseCase(
accountId = account.accountStatus.account.accountId,
currency = this.currency,
).map { actionsState -> actionsState.cryptoCurrencyStatus.currency to actionsState }
account.addedCurrency.map { it.actionsFlow() }
}.flatten()
val allAddedTokenActions =
portfolio.portfolios.map { portfolio -> portfolio.actionsFoAccountCurrencies() }.flatten()
return combine(
flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(),
flow2 = buttonState.distinctUntilChanged(),
flow3 = getExpandedHolder(portfolio),
flow4 = settingsFlow.distinctUntilChanged(),
transform = { actions, addButtonState, expanded, settings ->
buildContentState(
portfolio = portfolio,
allActions = actions,
addButtonState = addButtonState,
expanded = expanded,
settings = settings,
)
},
)
}
private fun getExpandedHolder(
portfolio: PortfoliosWithThisCurrency,
): StateFlow<Set<Pair<UserWalletId, CryptoCurrency.ID>>> {
val expandedHolder = this.expandedHolder
if (expandedHolder != null) return expandedHolder
val allAddedCurrency = portfolio.flattenAddedCurrency
val shouldForceExpand = allAddedCurrency.size == 1 &&
allAddedCurrency.first().value.amount?.isZero() == true
val initValue = when {
shouldForceExpand -> {
val currency = allAddedCurrency.first()
// find userWallet than have this single added token
portfolio.portfolios
.find { it.accountsWithAdded.find { account -> account.addedCurrency.isNotEmpty() } != null }
?.userWallet
?.let { setOf(it.walletId to currency.currency.id) }
?: setOf()
}
else -> setOf()
}
return MutableStateFlow(initValue)
.also { this.expandedHolder = it }
}
private fun portfolioWithThisCurrencyFLow(): Flow<PortfoliosWithThisCurrency> =
allAccountSupplier().map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows ->
combine(flows) {
PortfoliosWithThisCurrency(
currencyRawId = currencyRawId,
portfolios = it.toList(),
)
}
}.distinctUntilChanged()
private fun AccountStatusList.addedAccountsFlow(): Flow<Portfolio> =
getUserWalletUseCase.invokeFlow(this.userWalletId).mapNotNull { it.getOrNull() }.map { wallet ->
Portfolio(
userWallet = wallet,
accountStatusList = this,
accountsWithAdded = this.filterByRawID(),
)
}.distinctUntilChanged()
private fun AccountStatusList.filterByRawID(): List<AccountWithAdded> {
fun AccountStatus.filterByRawID(): List<CryptoCurrencyStatus> = when (this) {
is AccountStatus.CryptoPortfolio -> this.tokenList.flattenCurrencies()
.filter { status -> status.currency.id.rawCurrencyId == currencyRawId }
}
return accountStatuses.map { accountStatus ->
AccountWithAdded(
accountStatus = accountStatus,
addedCurrency = accountStatus.filterByRawID(),
)
}
}
private fun buildContentState(
portfolio: PortfoliosWithThisCurrency,
allActions: Map<CryptoCurrency, TokenActionsState>,
addButtonState: AddButtonState,
expanded: Set<Pair<UserWalletId, CryptoCurrency.ID>>,
settings: SettingsBox,
): MyPortfolioUM.Content {
val appCurrency = settings.appCurrency
val isBalanceHidden = settings.isBalanceHidden
val isAccountMode = settings.isAccountMode
val uiItems: MutableList<PortfolioListItem> = mutableListOf()
fun toggleQuickActions(key: Pair<UserWalletId, CryptoCurrency.ID>) = expandedHolder?.update { expanded ->
val isExpand = expanded.contains(key)
if (isExpand) expanded.minus(key) else expanded.plus(key)
}
val tokenUMConverter = PortfolioTokenUMConverter(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
onTokenItemClick = { },
tokenActionsHandler = tokenActionsHandler,
)
portfolio.portfolios.forEach { portfolioItem ->
if (portfolioItem.flattenAddedCurrency.isEmpty()) return@forEach
val userWallet = portfolioItem.userWallet
if (isAccountMode) {
uiItems.add(portfolioItem.userWallet.toWalletHeader())
} else {
uiItems.add(portfolioItem.userWallet.toWalletPortfolioHeader())
}
portfolioItem.accountsWithAdded.forEach { accountWithAdded ->
if (accountWithAdded.addedCurrency.isEmpty()) return@forEach
if (isAccountMode) {
val account = accountWithAdded.accountStatus.account
uiItems.add(account.toAccountPortfolioHeader())
}
accountWithAdded.addedCurrency.forEach { currencyStatus ->
val actions = allActions[currencyStatus.currency]?.states
?: emptyList()
val value = PortfolioData.CryptoCurrencyData(
userWallet = userWallet,
status = currencyStatus,
actions = actions,
)
val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id
val isExpand = expanded.contains(expandedKey)
val tokenItem = tokenUMConverter.convertV2(
onTokenItemClick = { wallet, status ->
toggleQuickActions(wallet.walletId to status.currency.id)
},
value = value,
isQuickActionsShown = isExpand,
)
uiItems.add(tokenItem)
}
}
}
return MyPortfolioUM.Content(
items = uiItems.toImmutableList(),
buttonState = addButtonState,
onAddClick = onAddClick,
)
}
private fun Account.toAccountPortfolioHeader(): PortfolioHeader = PortfolioHeader(
id = this.accountId.value,
state = AccountTitleUM.Account(
prefixText = TextReference.EMPTY,
name = this.accountName.toUM().value,
icon = when (this) {
is Account.CryptoPortfolio -> this.icon.toUM()
},
),
)
private fun UserWallet.toWalletPortfolioHeader(): PortfolioHeader = PortfolioHeader(
id = this.walletId.stringValue,
state = AccountTitleUM.Text(
title = stringReference(this.name),
),
)
private fun UserWallet.toWalletHeader(): WalletHeader = WalletHeader(
id = this.walletId.stringValue,
name = stringReference(this.name),
)
@Suppress("LongParameterList")
@AssistedFactory
interface Factory {
fun create(
scope: CoroutineScope,
token: TokenMarketParams,
tokenActionsHandler: TokenActionsHandler,
buttonState: Flow<AddButtonState>,
onAddClick: () -> Unit,
): NewMarketsPortfolioDelegate
}
}
private data class PortfoliosWithThisCurrency(
val currencyRawId: CryptoCurrency.RawID,
val portfolios: List<Portfolio>,
) {
val hasMultiWallets: Boolean = portfolios.any { it.userWallet.isMultiCurrency }
val flattenAddedCurrency: List<CryptoCurrencyStatus> =
portfolios.map { portfolio -> portfolio.flattenAddedCurrency }.flatten()
}
private data class Portfolio(
val userWallet: UserWallet,
val accountStatusList: AccountStatusList,
val accountsWithAdded: List<AccountWithAdded>,
) {
val flattenAddedCurrency: List<CryptoCurrencyStatus> =
accountsWithAdded.map { it.addedCurrency }.flatten()
}
private data class AccountWithAdded(
val addedCurrency: List<CryptoCurrencyStatus>,
val accountStatus: AccountStatus,
)
private data class SettingsBox(
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val isAccountMode: Boolean,
)

View file

@ -27,6 +27,24 @@ internal class PortfolioTokenUMConverter(
private val tokenActionsHandler: TokenActionsHandler,
) : Converter<PortfolioData.CryptoCurrencyData, PortfolioTokenUM> {
fun convertV2(
value: PortfolioData.CryptoCurrencyData,
isQuickActionsShown: Boolean,
onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit,
): PortfolioTokenUM {
val tokenItemStateConverter = TokenItemStateConverter(
appCurrency = appCurrency,
onItemClick = { _, status -> onTokenItemClick(value.userWallet, status) },
)
return PortfolioTokenUM(
tokenItemState = tokenItemStateConverter.convert(value = value.status),
walletId = value.userWallet.walletId,
isBalanceHidden = isBalanceHidden,
isQuickActionsShown = isQuickActionsShown,
quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler),
)
}
override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM {
val tokenItemStateConverter = TokenItemStateConverter(
appCurrency = appCurrency,
@ -84,7 +102,7 @@ internal class PortfolioTokenUMConverter(
)
}
private fun toQuickActions(actions: List<TokenActionsState.ActionState>) = buildList {
fun toQuickActions(actions: List<TokenActionsState.ActionState>) = buildList {
actions.forEach { action ->
if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) {
when (action) {

View file

@ -172,7 +172,7 @@ internal class TokenActionsHandler @AssistedInject constructor(
router.push(
AppRoute.Staking(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrencyId = cryptoCurrencyData.status.currency.id,
cryptoCurrency = cryptoCurrencyData.status.currency,
yieldId = yield.id,
),
)

View file

@ -10,29 +10,43 @@ 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.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.account.AccountTitle
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SmallButtonShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
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.markets.impl.R
import com.tangem.features.markets.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.markets.portfolio.impl.ui.state.*
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens.AddButtonState
@Composable
internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) {
if (state is MyPortfolioUM.Content) {
val contentModifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing20,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing32,
)
PortfolioList(state, contentModifier)
return
}
InformationBlock(
modifier = modifier,
contentHorizontalPadding = TangemTheme.dimens.spacing0,
@ -55,6 +69,7 @@ internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) {
MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier)
MyPortfolioUM.Unavailable -> UnavailableAsset(modifier = contentModifier)
MyPortfolioUM.UnavailableForWallet -> UnavailableAssetForWallet(modifier = contentModifier)
is MyPortfolioUM.Content -> PortfolioList(state = state)
}
}
@ -119,6 +134,7 @@ private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier
state.tokens.fastForEachIndexed { index, token ->
key(token.tokenItemState.id) {
PortfolioItem(
modifier = Modifier.background(color = TangemTheme.colors.background.action),
state = token,
lastInList = index == state.tokens.size - 1,
)
@ -129,6 +145,111 @@ private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier
TokenReceiveBottomSheet(config = state.tokenReceiveBSConfig)
}
@Composable
private fun PortfolioList(state: MyPortfolioUM.Content, modifier: Modifier = Modifier) {
Column(modifier) {
key("PortfolioListHeader") {
Row(
modifier = Modifier.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = stringResourceSafe(R.string.markets_common_my_portfolio),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
AddButton(state = state.buttonState, onClick = state.onAddClick)
}
}
state.items.fastForEachIndexed { index, item ->
val previousItem = state.items.getOrNull(index.dec())
val nextItem = state.items.getOrNull(index.inc())
val itemModifier = Modifier
.fillMaxWidth()
.getOffsetModifier(item, previousItem)
.getBackgroundModifier(item, previousItem, nextItem)
key(item.id) {
PortfolioItem(
item = item,
modifier = itemModifier,
lastInList = index == state.items.size - 1,
)
}
}
}
}
@Composable
private fun Modifier.getBackgroundModifier(
item: PortfolioListItem,
previousItem: PortfolioListItem?,
nextItem: PortfolioListItem?,
): Modifier {
val color = TangemTheme.colors.background.action
val radius = 14.dp
val topRound = RoundedCornerShape(topStart = radius, topEnd = radius)
val bottomRound = RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
val allRound = RoundedCornerShape(size = radius)
val backgroundModifier = when (item) {
is WalletHeader -> this
is PortfolioHeader -> this
.clip(topRound)
.background(color = color)
is PortfolioTokenUM -> when {
previousItem is PortfolioHeader && nextItem !is PortfolioTokenUM -> this
.clip(bottomRound)
.background(color = color)
previousItem is WalletHeader && nextItem !is PortfolioTokenUM -> this
.clip(allRound)
.background(color = color)
previousItem is PortfolioTokenUM && nextItem !is PortfolioTokenUM -> this
.clip(bottomRound)
.background(color = color)
else -> this.background(color = color)
}
}
return backgroundModifier
}
private fun Modifier.getOffsetModifier(item: PortfolioListItem, previousItem: PortfolioListItem?): Modifier = when {
item is WalletHeader -> this.padding(top = 20.dp, start = 4.dp, end = 4.dp)
item is PortfolioHeader && previousItem is PortfolioTokenUM -> this.padding(top = 12.dp)
item is PortfolioHeader && previousItem == null -> this.padding(top = 20.dp)
previousItem is WalletHeader -> this.padding(top = 12.dp)
previousItem is PortfolioTokenUM -> this
else -> this
}
@Composable
private fun PortfolioItem(item: PortfolioListItem, lastInList: Boolean, modifier: Modifier = Modifier) {
when (item) {
is PortfolioHeader -> AccountTitle(
modifier = modifier.padding(
start = 12.dp,
top = 12.dp,
bottom = 8.dp,
),
accountTitleUM = item.state,
textStyle = TangemTheme.typography.caption1,
textColor = TangemTheme.colors.text.primary1,
)
is PortfolioTokenUM -> PortfolioItem(
state = item,
modifier = modifier,
lastInList = lastInList,
)
is WalletHeader -> Text(
modifier = modifier,
text = item.name.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
}
}
@Composable
fun UnavailableAsset(modifier: Modifier = Modifier) {
UnavailableContent(
@ -199,8 +320,7 @@ private fun Preview(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state
TangemThemePreview {
Box(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing8),
.background(TangemTheme.colors.background.tertiary),
) {
MyPortfolio(state)
}

View file

@ -45,7 +45,6 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie
TokenItem(
state = tokenItemState,
isBalanceHidden = state.isBalanceHidden,
modifier = Modifier.background(color = TangemTheme.colors.background.action),
itemPaddingValues = PaddingValues(
start = TangemTheme.dimens.spacing10,
end = TangemTheme.dimens.spacing12,
@ -54,7 +53,6 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie
PortfolioQuickActions(
modifier = Modifier
.background(color = TangemTheme.colors.background.action)
.padding(
bottom = if (lastInList) {
TangemTheme.dimens.spacing12
@ -82,6 +80,7 @@ private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM:
}
PortfolioItem(
modifier = Modifier.background(color = TangemTheme.colors.background.action),
state = tokenUM.copy(
tokenItemState = when (tokenUM.tokenItemState) {
is TokenItemState.Content -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() })

View file

@ -1,15 +1,19 @@
package com.tangem.features.markets.portfolio.impl.ui.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
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.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.markets.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.markets.portfolio.impl.ui.state.QuickActionUM
import com.tangem.features.markets.portfolio.impl.ui.state.*
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM.Tokens
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider<MyPortfolioUM> {
@ -40,35 +44,107 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider<MyPortfol
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
onAddClick = {},
),
MyPortfolioUM.Content(
items = persistentListOf(
walletPortfolioHeader,
accountToken,
accountToken,
),
buttonState = Tokens.AddButtonState.Available,
onAddClick = {},
),
MyPortfolioUM.Content(
items = persistentListOf(
walletHeader,
accountHeader,
accountToken,
accountToken,
),
buttonState = Tokens.AddButtonState.Available,
onAddClick = {},
),
MyPortfolioUM.Content(
items = persistentListOf(
walletHeader,
accountHeader,
accountToken.copy(isQuickActionsShown = true),
accountToken,
),
buttonState = Tokens.AddButtonState.Available,
onAddClick = {},
),
MyPortfolioUM.Loading,
MyPortfolioUM.Unavailable,
MyPortfolioUM.UnavailableForWallet,
)
val sampleToken = PortfolioTokenUM(
tokenItemState = TokenItemState.Content(
id = "",
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "My wallet")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "486,65 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "733,71097 MATIC"),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference(value = "XRP Ledger token"),
val walletHeader
get() = WalletHeader(
id = UUID.randomUUID().toString(),
name = stringReference("Wallet 1"),
)
val walletPortfolioHeader
get() = PortfolioHeader(
state = AccountTitleUM.Text(title = stringReference("Wallet 1")),
id = UUID.randomUUID().toString(),
)
val accountHeader
get() = PortfolioHeader(
state = AccountTitleUM.Account(
icon = AccountIconPreviewData.randomAccountIcon(),
name = stringReference("Main Account"),
prefixText = TextReference.EMPTY,
),
onItemClick = {},
onItemLongClick = {},
),
isQuickActionsShown = false,
quickActions = PortfolioTokenUM.QuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(showBadge = true),
QuickActionUM.Receive,
id = UUID.randomUUID().toString(),
)
val coinIconState
get() = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22,
isGrayscale = false,
shouldShowCustomBadge = false,
)
val accountToken
get() = sampleToken.copy(
tokenItemState = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = coinIconState,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")),
fiatAmountState = FiatAmountState.Content(text = "321 $"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"),
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(value = "Token")),
onItemClick = {},
onItemLongClick = {},
),
onQuickActionClick = {},
onQuickActionLongClick = {},
),
isBalanceHidden = false,
walletId = UserWalletId(""),
)
)
val sampleToken
get() = PortfolioTokenUM(
tokenItemState = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "My wallet")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "486,65 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "733,71097 MATIC"),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference(value = "XRP Ledger token"),
),
onItemClick = {},
onItemLongClick = {},
),
isQuickActionsShown = false,
quickActions = PortfolioTokenUM.QuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(showBadge = true),
QuickActionUM.Receive,
),
onQuickActionClick = {},
onQuickActionLongClick = {},
),
isBalanceHidden = false,
walletId = UserWalletId(""),
)
}

View file

@ -24,6 +24,15 @@ internal sealed class MyPortfolioUM {
}
}
data class Content(
val items: ImmutableList<PortfolioListItem>,
val buttonState: Tokens.AddButtonState,
val onAddClick: () -> Unit,
) : MyPortfolioUM() {
override val addToPortfolioBSConfig: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty
}
data class AddFirstToken(
override val addToPortfolioBSConfig: TangemBottomSheetConfig,
val onAddClick: () -> Unit,

View file

@ -1,16 +1,33 @@
package com.tangem.features.markets.portfolio.impl.ui.state
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.collections.immutable.ImmutableList
internal sealed interface PortfolioListItem {
val id: String
}
internal data class WalletHeader(
override val id: String,
val name: TextReference,
) : PortfolioListItem
internal data class PortfolioHeader(
override val id: String,
val state: AccountTitleUM,
) : PortfolioListItem
internal data class PortfolioTokenUM(
val tokenItemState: TokenItemState,
val walletId: UserWalletId,
val isBalanceHidden: Boolean,
val isQuickActionsShown: Boolean,
val quickActions: QuickActions,
) {
) : PortfolioListItem {
override val id: String = tokenItemState.id
data class QuickActions(
val actions: ImmutableList<QuickActionUM>,

View file

@ -13,6 +13,7 @@ android {
dependencies {
/** Api */
implementation(projects.features.account.api)
implementation(projects.features.nft.api)
implementation(projects.features.tokenRecieve.api)
@ -28,6 +29,8 @@ dependencies {
implementation(projects.core.datasource)
/** Domain modules */
implementation(projects.domain.account)
implementation(projects.domain.wallets)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.models)

View file

@ -1,10 +1,20 @@
package com.tangem.features.nft.collections.entity
import androidx.annotation.DrawableRes
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.extensions.TextReference
internal sealed interface NFTCollectionItem {
val id: String
}
internal data class NFTCollectionPortfolioUM(
override val id: String,
val title: AccountTitleUM,
) : NFTCollectionItem
internal data class NFTCollectionUM(
val id: String,
override val id: String,
val name: String,
@DrawableRes val networkIconId: Int,
val logoUrl: String?,
@ -12,4 +22,4 @@ internal data class NFTCollectionUM(
val assets: NFTCollectionAssetsListUM,
val isExpanded: Boolean,
val onExpandClick: () -> Unit,
)
) : NFTCollectionItem

View file

@ -23,7 +23,7 @@ internal sealed class NFTCollectionsUM {
data class Content(
val search: SearchBarUM,
val collections: ImmutableList<NFTCollectionUM>,
val collections: ImmutableList<NFTCollectionItem>,
val warnings: ImmutableList<NFTCollectionsWarningUM>,
val onReceiveClick: () -> Unit,
) : NFTCollectionsUM()

View file

@ -1,6 +1,7 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.features.nft.collections.entity.NFTCollectionUM
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.utils.transformer.Transformer
@ -21,7 +22,7 @@ internal class ChangeCollectionExpandedStateTransformer(
is NFTCollectionsUM.Content -> prevState.content.copy(
collections = prevState.content.collections.map {
val collectionId = collection.collectionIdProvider()
if (it.id == collectionId) {
if (it.id == collectionId && it is NFTCollectionUM) {
if (!it.isExpanded) {
onFirstExpanded()
}

View file

@ -1,10 +1,13 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.account.Account
import com.tangem.domain.nft.models.*
import com.tangem.features.nft.collections.entity.*
import com.tangem.features.nft.impl.R
@ -16,6 +19,8 @@ import kotlinx.collections.immutable.toPersistentList
@Suppress("LongParameterList")
internal class UpdateDataStateTransformer(
private val nftCollections: List<NFTCollections>,
private val walletNFTCollections: WalletNFTCollections? = null,
private val isAccountMode: Boolean = false,
private val onReceiveClick: () -> Unit,
private val onRetryClick: () -> Unit,
private val onExpandCollectionClick: (NFTCollection) -> Unit,
@ -25,7 +30,9 @@ internal class UpdateDataStateTransformer(
private val collectionIdProvider: NFTCollection.() -> String,
) : Transformer<NFTCollectionsStateUM> {
@Suppress("CyclomaticComplexMethod")
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM {
val nftCollections = walletNFTCollections?.flattenCollections ?: this.nftCollections
val hasQuery = !(prevState.content as? NFTCollectionsUM.Content)?.search?.query.isNullOrEmpty()
val content = when {
!hasQuery && nftCollections.allCollectionsFailed() ->
@ -66,16 +73,45 @@ internal class UpdateDataStateTransformer(
} else {
initialSearchBarFactory()
},
collections = nftCollections
collections = walletNFTCollections
?.let { createCollections(it) }
?: createNFTsUM(nftCollections).toPersistentList(),
warnings = transformNotifications(),
onReceiveClick = onReceiveClick,
)
private fun NFTCollectionsStateUM.createCollections(walletNFTCollections: WalletNFTCollections) =
if (isAccountMode) {
val result = mutableListOf<NFTCollectionItem>()
walletNFTCollections.collections.forEach { (account, nfts) ->
if (nfts.isEmpty()) return@forEach
result.add(account.toAccountPortfolioUM())
result.addAll(createNFTsUM(nfts))
}
result.toPersistentList()
} else {
val mainAccountCollection = walletNFTCollections.collections.values.firstOrNull() ?: listOf()
createNFTsUM(mainAccountCollection).toPersistentList()
}
private fun Account.toAccountPortfolioUM(): NFTCollectionPortfolioUM = NFTCollectionPortfolioUM(
id = this.accountId.value,
title = AccountTitleUM.Account(
prefixText = TextReference.EMPTY,
name = this.accountName.toUM().value,
icon = when (this) {
is Account.CryptoPortfolio -> this.icon.toUM()
},
),
)
private fun NFTCollectionsStateUM.createNFTsUM(nftCollections: List<NFTCollections>): Sequence<NFTCollectionUM> =
nftCollections
.map { it.content }
.asSequence()
.filterIsInstance<NFTCollections.Content.Collections>()
.map { it.collections.orEmpty().transform(this) }
.flatten()
.toPersistentList(),
warnings = transformNotifications(),
onReceiveClick = onReceiveClick,
)
private fun List<NFTCollection>.transform(state: NFTCollectionsStateUM): ImmutableList<NFTCollectionUM> = map {
NFTCollectionUM(
@ -97,6 +133,8 @@ internal class UpdateDataStateTransformer(
}.toPersistentList()
private fun transformNotifications(): ImmutableList<NFTCollectionsWarningUM> = buildList {
val nftCollections = walletNFTCollections?.flattenCollections
?: this@UpdateDataStateTransformer.nftCollections
if (nftCollections.anyCollectionFailed()) {
add(
NFTCollectionsWarningUM(
@ -152,6 +190,7 @@ internal class UpdateDataStateTransformer(
private fun NFTCollection.isExpanded(state: NFTCollectionsStateUM): Boolean =
(state.content as? NFTCollectionsUM.Content)
?.collections
?.filterIsInstance<NFTCollectionUM>()
?.firstOrNull { it.id == this.collectionIdProvider() }
?.isExpanded
?: false

View file

@ -7,6 +7,8 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.nft.FetchNFTCollectionAssetsUseCase
import com.tangem.domain.nft.GetNFTCollectionsUseCase
import com.tangem.domain.nft.RefreshAllNFTUseCase
@ -31,6 +33,8 @@ internal class NFTCollectionsModel @Inject constructor(
private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase,
private val fetchNFTCollectionAssetsUseCase: FetchNFTCollectionAssetsUseCase,
private val refreshAllNFTUseCase: RefreshAllNFTUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
paramsContainer: ParamsContainer,
) : Model() {
@ -62,7 +66,11 @@ internal class NFTCollectionsModel @Inject constructor(
}
init {
subscribeToNFTCollections()
if (accountsFeatureToggles.isFeatureEnabled) {
subscribeToNFTCollectionsNew()
} else {
subscribeToNFTCollections()
}
}
private fun subscribeToNFTCollections() {
@ -91,6 +99,38 @@ internal class NFTCollectionsModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeToNFTCollectionsNew() {
combine(
flow = getNFTCollectionsUseCase.invokeForAccounts(params.userWalletId),
flow2 = searchManager.query.distinctUntilChanged(),
flow3 = isAccountsModeEnabledUseCase(),
) { nftCollections, query, isAccountMode ->
val filteredNFTs = nftCollections.collections
.mapValues { (_, nfts) -> nfts.filter(query) }
_state.update {
UpdateDataStateTransformer(
nftCollections = listOf(),
isAccountMode = isAccountMode,
walletNFTCollections = nftCollections.copy(collections = filteredNFTs),
onReceiveClick = {
params.onReceiveClick()
},
onRetryClick = ::onRefresh,
onExpandCollectionClick = ::onExpandCollectionClick,
onRetryAssetsClick = ::onRetryAssetsClick,
onAssetClick = { asset, collection ->
params.onAssetClick(asset, collection)
},
initialSearchBarFactory = ::getInitialSearchBar,
collectionIdProvider = collectionIdProvider,
).transform(it)
}
}
.onStart { onRefresh() }
.launchIn(modelScope)
}
private fun List<NFTCollections>.filter(query: String): List<NFTCollections> = map {
it.copy(
content = when (val content = it.content) {

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