Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-13 13:02:12 +03:00
commit 3946e4ebca
359 changed files with 8459 additions and 3324 deletions

View file

@ -28,7 +28,7 @@ interface PortfolioFetcher {
val walletBalance: Lce<TokenListError, TotalFiatBalance>,
val accountsBalance: AccountStatusList,
) {
val userWallet: UserWallet get() = accountsBalance.userWallet
val userWalletId: UserWalletId get() = accountsBalance.userWalletId
}
sealed interface Mode {

View file

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

View file

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

View file

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

View file

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

View file

@ -125,7 +125,6 @@ internal object PortfolioSelectorPreviewData {
name = accountName,
icon = AccountIconPreviewData.randomAccountIcon(),
),
label = null,
)
private val walletItem: UserWalletItemUM
@ -137,7 +136,6 @@ internal object PortfolioSelectorPreviewData {
isEnabled = true,
onClick = { },
imageState = ImageState.MobileWallet,
label = null,
)
private val lockedWalletItem: UserWalletItemUM

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
/build

View file

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

View file

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

View file

@ -0,0 +1 @@
/build

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -86,8 +86,7 @@ internal class HomeModel @Inject constructor(
onScanClick = ::onScanClick,
onShopClick = ::onShopClick,
onSearchTokensClick = ::onSearchTokensClick,
onCreateNewWalletClick = ::onCreateNewWalletClick,
onAddExistingWalletClick = ::onAddExistingWalletClick,
onGetStartedClick = ::onGetStartedClick,
),
)
@ -145,12 +144,8 @@ internal class HomeModel @Inject constructor(
router.push(AppRoute.ManageTokens(Source.STORIES))
}
private fun onCreateNewWalletClick() {
router.push(AppRoute.CreateWalletSelection)
}
private fun onAddExistingWalletClick() {
router.push(AppRoute.AddExistingWallet)
private fun onGetStartedClick() {
router.push(AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.ColdWallet))
}
private fun scanCard() {
@ -240,7 +235,7 @@ internal class HomeModel @Inject constructor(
_uiState.update { it.copy(scanInProgress = isLoading) }
}
fun handleScanError(error: TangemError) {
private fun handleScanError(error: TangemError) {
when (error) {
is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable()
is TangemSdkError -> Timber.e(error, "Scan error occurred")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.entity.TokenListUMData
import kotlinx.collections.immutable.persistentListOf
internal class PreviewTokenListUMProvider : PreviewParameterProvider<TokenListUM> {
@ -42,6 +43,7 @@ internal class PreviewTokenListUMProvider : PreviewParameterProvider<TokenListUM
createUnavailableTokenItem(),
createUnavailableTokenItem(),
),
tokensListData = TokenListUMData.EmptyList,
isBalanceHidden = false,
)
}

View file

@ -454,7 +454,7 @@ internal class SendModel @Inject constructor(
}
private fun initialState(): SendUM = SendUM(
amountUM = AmountState.Empty(isRedesignEnabled = true),
amountUM = AmountState.Empty,
destinationUM = SendDestinationInitialStateTransformer(
cryptoCurrency = cryptoCurrency,
).transform(DestinationUM.Empty()),

View file

@ -26,12 +26,10 @@ internal class SendAmountComponent(
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle()
val isSendWithSwapAvailable by model.isSendWithSwapAvailable.collectAsStateWithLifecycle()
SendAmountContent(
amountState = state,
isBalanceHidden = isBalanceHidden,
clickIntents = model,
isSendWithSwapAvailable = isSendWithSwapAvailable,
modifier = modifier,

View file

@ -172,7 +172,7 @@ internal class SendAmountModel @Inject constructor(
if (uiState.value is AmountState.Empty && userWallet != null) {
val isOnlyOneWallet = getWalletsUseCase.invokeSync().size == 1
_uiState.update {
AmountStateConverterV2(
AmountStateConverter(
clickIntents = this,
appCurrency = appCurrency,
cryptoCurrencyStatus = cryptoCurrencyStatus,

View file

@ -30,7 +30,6 @@ import com.tangem.features.send.v2.subcomponents.amount.ui.preview.SendAmountCli
@Composable
fun SendAmountContent(
amountState: AmountState,
isBalanceHidden: Boolean,
clickIntents: SendAmountClickIntents,
isSendWithSwapAvailable: Boolean,
modifier: Modifier = Modifier,
@ -38,7 +37,6 @@ fun SendAmountContent(
Column(modifier = modifier.background(TangemTheme.colors.background.tertiary)) {
AmountScreenContent(
amountState = amountState,
isBalanceHidden = isBalanceHidden,
clickIntents = clickIntents,
extraContent = {
SendConvertTokenButton(
@ -95,7 +93,6 @@ private fun SendAmountContent_Preview(@PreviewParameter(SendAmountContentPreview
TangemThemePreview {
SendAmountContent(
amountState = params,
isBalanceHidden = true,
clickIntents = SendAmountClickIntentsStub,
isSendWithSwapAvailable = true,
)

View file

@ -201,16 +201,11 @@ class SendConfirmationNotificationsTransformerV2Test {
return AmountState.Data(
isPrimaryButtonEnabled = true,
isRedesignEnabled = false,
title = mockk(relaxed = true),
availableBalance = mockk(relaxed = true),
availableBalanceCrypto = mockk(relaxed = true),
availableBalanceFiat = mockk(relaxed = true),
tokenName = mockk(relaxed = true),
tokenIconState = mockk(relaxed = true),
segmentedButtonConfig = persistentListOf(),
selectedButton = 0,
isSegmentedButtonsEnabled = false,
amountTextField = AmountFieldModel(
value = "1.5",
onValueChange = {},

View file

@ -52,7 +52,9 @@ internal class StakingAnalyticSender(
source = when (value.currentStep) {
StakingStep.InitialInfo -> StakeScreenSource.Info
StakingStep.Amount -> StakeScreenSource.Amount
StakingStep.Confirmation -> StakeScreenSource.Confirmation
StakingStep.Success,
StakingStep.Confirmation,
-> StakeScreenSource.Confirmation
StakingStep.Validators,
StakingStep.RestakeValidator,
StakingStep.RewardsValidators,

View file

@ -87,7 +87,7 @@ internal class StakingStateController @Inject constructor(
cryptoCurrencyBlockchainId = "",
currentStep = StakingStep.InitialInfo,
initialInfoState = StakingStates.InitialInfoState.Empty(),
amountState = AmountState.Empty(isRedesignEnabled = false),
amountState = AmountState.Empty,
validatorState = StakingStates.ValidatorState.Empty(),
rewardsValidatorsState = StakingStates.RewardsValidatorsState.Empty(),
confirmationState = StakingStates.ConfirmationState.Empty(),

View file

@ -42,6 +42,7 @@ internal class StakingStateRouter(
StakingStep.Amount,
-> showConfirmation()
StakingStep.Confirmation -> showInitial()
StakingStep.Success -> appRouter.pop()
}
}
@ -65,6 +66,7 @@ internal class StakingStateRouter(
}
}
StakingStep.Validators -> showConfirmation()
StakingStep.Success -> appRouter.pop()
}
}

View file

@ -138,4 +138,5 @@ enum class StakingStep {
RestakeValidator,
Confirmation,
Validators,
Success,
}

View file

@ -46,10 +46,11 @@ internal class SetAmountDataTransformer(
return prevState.copy(
amountState = AmountStateConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
appCurrencyProvider = appCurrencyProvider,
iconStateConverter = iconStateConverter,
maxEnterAmount = maxEnterAmount,
appCurrency = appCurrencyProvider(),
cryptoCurrencyStatus = cryptoCurrencyStatusProvider(),
isBalanceHidden = false,
).convert(
AmountParameters(
title = title,

View file

@ -25,7 +25,6 @@ internal class SetButtonsStateTransformer(
val buttonsState = if (prevState.isButtonsVisible()) {
NavigationButtonsState.Data(
primaryButton = getPrimaryButton(prevState),
prevButton = getPrevButton(prevState),
extraButtons = getExtraButtons(prevState).takeIf { txUrl != null },
txUrl = txUrl,
onTextClick = urlOpener::openUrl,
@ -64,18 +63,6 @@ internal class SetButtonsStateTransformer(
)
}
private fun getPrevButton(prevState: StakingUiState): NavigationButton? {
return NavigationButton(
textReference = TextReference.EMPTY,
iconRes = R.drawable.ic_back_24,
isSecondary = true,
isIconVisible = true,
shouldShowProgress = false,
isEnabled = true,
onClick = prevState.clickIntents::onPrevClick,
).takeIf { prevState.currentStep.isPrevButtonVisible() }
}
private fun getExtraButtons(prevState: StakingUiState): Pair<NavigationButton, NavigationButton> {
return NavigationButton(
textReference = resourceReference(R.string.common_explore),
@ -111,7 +98,7 @@ internal class SetButtonsStateTransformer(
resourceReference(R.string.common_stake)
}
}
StakingStep.Success -> resourceReference(R.string.common_close)
StakingStep.Confirmation -> getConfirmationButtonText()
StakingStep.Validators -> resourceReference(R.string.common_continue)
StakingStep.Amount,
@ -125,21 +112,17 @@ internal class SetButtonsStateTransformer(
val confirmationState = confirmationState as? StakingStates.ConfirmationState.Data
val amountState = amountState as? AmountState.Data
return if (confirmationState != null && amountState != null) {
if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) {
resourceReference(R.string.common_close)
} else {
when (actionType) {
is StakingActionCommonType.Enter -> {
val amount = amountState.amountTextField.cryptoAmount.value.orZero()
if (confirmationState.isApprovalNeeded && confirmationState.allowance < amount) {
resourceReference(R.string.give_permission_title)
} else {
resourceReference(R.string.common_stake)
}
when (actionType) {
is StakingActionCommonType.Enter -> {
val amount = amountState.amountTextField.cryptoAmount.value.orZero()
if (confirmationState.isApprovalNeeded && confirmationState.allowance < amount) {
resourceReference(R.string.give_permission_title)
} else {
resourceReference(R.string.common_stake)
}
is StakingActionCommonType.Exit -> resourceReference(R.string.common_unstake)
is StakingActionCommonType.Pending -> confirmationState.pendingAction?.type.getPendingActionTitle()
}
is StakingActionCommonType.Exit -> resourceReference(R.string.common_unstake)
is StakingActionCommonType.Pending -> confirmationState.pendingAction?.type.getPendingActionTitle()
}
} else {
resourceReference(R.string.common_close)
@ -155,6 +138,7 @@ internal class SetButtonsStateTransformer(
StakingStep.Amount -> clickIntents.onAmountEnterClick()
StakingStep.Confirmation -> onConfirmationClick()
StakingStep.RewardsValidators -> Unit
StakingStep.Success -> clickIntents.onBackClick()
}
}
@ -178,17 +162,6 @@ internal class SetButtonsStateTransformer(
}
}
private fun StakingStep.isPrevButtonVisible(): Boolean = when (this) {
StakingStep.InitialInfo,
StakingStep.RewardsValidators,
StakingStep.RestakeValidator,
StakingStep.Confirmation,
StakingStep.Validators,
-> false
StakingStep.Amount,
-> true
}
private fun StakingUiState.isPrimaryButtonDisabled(): Boolean {
val initialState = initialInfoState as? StakingStates.InitialInfoState.Data
val hasNotStaking = initialState?.yieldBalance == InnerYieldBalanceState.Empty
@ -205,6 +178,7 @@ internal class SetButtonsStateTransformer(
StakingStep.RewardsValidators -> rewardsValidatorsState.isPrimaryButtonEnabled
StakingStep.RestakeValidator,
StakingStep.Validators,
StakingStep.Success,
-> true
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingStep
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.TransactionDoneState
import com.tangem.utils.transformer.Transformer
@ -17,6 +18,7 @@ internal class SetConfirmationStateCompletedTransformer(
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
confirmationState = prevState.confirmationState.copyWrapped(),
currentStep = StakingStep.Success,
)
}

View file

@ -229,10 +229,11 @@ internal class SetInitialDataStateTransformer(
)
return AmountStateConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
appCurrencyProvider = appCurrencyProvider,
cryptoCurrencyStatus = cryptoCurrencyStatus,
appCurrency = appCurrencyProvider(),
iconStateConverter = iconStateConverter,
maxEnterAmount = maxEnterAmount,
isBalanceHidden = false,
).convert(
AmountParameters(
title = stringReference(userWalletProvider().name),

View file

@ -1,5 +1,6 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.isNullOrEmpty
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -28,7 +29,7 @@ internal object SetTitleTransformer : Transformer<StakingUiState> {
R.string.staking_title_stake,
wrappedList(prevState.cryptoCurrencyName),
)
StakingStep.Success -> TextReference.EMPTY
StakingStep.Confirmation -> {
when (actionType) {
is StakingActionCommonType.Enter -> resourceReference(

View file

@ -14,12 +14,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.common.ui.amountScreen.ui.AmountBlockV2
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState
import com.tangem.features.staking.impl.presentation.state.StakingNotification
import com.tangem.features.staking.impl.presentation.state.StakingStates
@ -30,7 +31,6 @@ import com.tangem.features.staking.impl.presentation.state.stub.StakingClickInte
import com.tangem.features.staking.impl.presentation.ui.block.NotificationsBlock
import com.tangem.features.staking.impl.presentation.ui.block.StakingFeeBlock
import com.tangem.features.staking.impl.presentation.ui.block.ValidatorBlock
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
@Suppress("LongParameterList")
@Composable
@ -60,7 +60,7 @@ internal fun StakingConfirmationContent(
subtitle = resourceReference(R.string.staking_transaction_in_progress_text),
)
}
AmountBlock(
AmountBlockV2(
amountState = amountState,
isClickDisabled = !state.isAmountEditable || isTransactionSent || isTransactionInProgress,
isEditingDisabled = !state.isAmountEditable && state.innerState != InnerConfirmationStakingState.COMPLETED,

View file

@ -94,6 +94,7 @@ private fun StakingAppBar(uiState: StakingUiState) {
val (backIcon, click) = when (uiState.currentStep) {
StakingStep.Amount,
StakingStep.Confirmation,
StakingStep.Success,
-> R.drawable.ic_close_24 to uiState.clickIntents::onBackClick
StakingStep.Validators,
StakingStep.RewardsValidators,
@ -111,6 +112,7 @@ private fun StakingAppBar(uiState: StakingUiState) {
)
}
@Suppress("LongMethod")
@Composable
private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = Modifier) {
val currentScreen = uiState.currentStep
@ -139,10 +141,10 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M
contentAlignment = Alignment.TopCenter,
label = "Staking Screen Navigation",
transitionSpec = {
val direction = if (initialState.ordinal < targetState.ordinal) {
AnimatedContentTransitionScope.SlideDirection.Start
} else {
AnimatedContentTransitionScope.SlideDirection.End
val direction = when {
targetState == StakingStep.Success -> AnimatedContentTransitionScope.SlideDirection.Up
initialState.ordinal < targetState.ordinal -> AnimatedContentTransitionScope.SlideDirection.Start
else -> AnimatedContentTransitionScope.SlideDirection.End
}
slideIntoContainer(towards = direction, animationSpec = tween())
@ -166,7 +168,6 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M
}
StakingStep.Amount -> AmountScreenContent(
amountState = uiState.amountState,
isBalanceHidden = uiState.isBalanceHidden,
clickIntents = uiState.clickIntents,
modifier = Modifier.background(TangemTheme.colors.background.secondary),
)
@ -176,6 +177,12 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M
validatorState = uiState.validatorState,
clickIntents = uiState.clickIntents,
)
StakingStep.Success -> StakingSuccessContent(
amountState = uiState.amountState,
state = uiState.confirmationState,
validatorState = uiState.validatorState,
clickIntents = uiState.clickIntents,
)
StakingStep.RestakeValidator,
StakingStep.Validators,
-> StakingValidatorListContent(

View file

@ -0,0 +1,86 @@
package com.tangem.features.staking.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState
import com.tangem.features.staking.impl.presentation.state.StakingNotification
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData
import com.tangem.features.staking.impl.presentation.state.previewdata.ValidatorStatePreviewData
import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub
import com.tangem.features.staking.impl.presentation.ui.block.NotificationsBlock
import com.tangem.features.staking.impl.presentation.ui.block.StakingFeeBlock
import com.tangem.features.staking.impl.presentation.ui.block.ValidatorBlock
@Suppress("LongParameterList")
@Composable
internal fun StakingSuccessContent(
amountState: AmountState,
state: StakingStates.ConfirmationState,
validatorState: StakingStates.ValidatorState,
clickIntents: StakingClickIntents,
) {
if (state !is StakingStates.ConfirmationState.Data) return
val isTransactionSent = state.innerState == InnerConfirmationStakingState.COMPLETED
val isTransactionInProgress = state.notifications.any { it is StakingNotification.Warning.TransactionInProgress }
Column(
modifier = Modifier
.background(TangemTheme.colors.background.secondary)
.padding(horizontal = TangemTheme.dimens.spacing16)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
TransactionDoneTitle(
title = resourceReference(R.string.common_in_progress),
subtitle = resourceReference(R.string.staking_transaction_in_progress_text),
)
AmountBlock(
amountState = amountState,
isClickDisabled = !state.isAmountEditable || isTransactionSent || isTransactionInProgress,
isEditingDisabled = !state.isAmountEditable && state.innerState != InnerConfirmationStakingState.COMPLETED,
onClick = clickIntents::onPrevClick,
)
ValidatorBlock(
validatorState = validatorState,
isClickable = !isTransactionInProgress,
onClick = clickIntents::openValidators,
)
StakingFeeBlock(feeState = state.feeState)
NotificationsBlock(notifications = state.notifications)
Spacer(Modifier)
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_StakingConfirmationContent() {
TangemThemePreview {
Column(Modifier.background(TangemTheme.colors.background.primary)) {
StakingConfirmationContent(
amountState = AmountStatePreviewData.amountState,
state = ConfirmationStatePreviewData.assentStakingState,
validatorState = ValidatorStatePreviewData.validatorState,
clickIntents = StakingClickIntentsStub,
)
}
}
}

View file

@ -64,19 +64,13 @@ sealed class SwapAmountFieldUM {
data class Empty(
override val amountType: SwapAmountType,
) : SwapAmountFieldUM() {
override val amountField: AmountState = AmountState.Empty(
isPrimaryButtonEnabled = false,
isRedesignEnabled = true,
)
override val amountField: AmountState = AmountState.Empty
}
data class Loading(
override val amountType: SwapAmountType,
) : SwapAmountFieldUM() {
override val amountField: AmountState = AmountState.Empty(
isPrimaryButtonEnabled = false,
isRedesignEnabled = true,
)
override val amountField: AmountState = AmountState.Empty
}
data class Content(

View file

@ -1,7 +1,7 @@
package com.tangem.features.swap.v2.impl.amount.model.converter
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.converters.AmountStateConverterV2
import com.tangem.common.ui.amountScreen.converters.AmountStateConverter
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.core.ui.components.atoms.text.TextEllipsis
@ -44,7 +44,7 @@ internal class SwapAmountFieldConverter(
subtitleEllipsisRight = TextEllipsis.OffsetEnd(appCurrency.symbol.length),
priceImpact = null,
isClickEnabled = selectedType.isViewingField(),
amountField = AmountStateConverterV2(
amountField = AmountStateConverter(
clickIntents = clickIntents,
appCurrency = appCurrency,
cryptoCurrencyStatus = cryptoCurrencyStatus,

View file

@ -46,9 +46,7 @@ internal class SwapAmountBalanceHiddenTransformer(
val newData = recalculatedPrimary.amountField
newData.copy(
amountTextField = oldData.amountTextField,
selectedButton = oldData.selectedButton,
isPrimaryButtonEnabled = oldData.isPrimaryButtonEnabled,
isSegmentedButtonsEnabled = oldData.isSegmentedButtonsEnabled,
isEditingDisabled = oldData.isEditingDisabled,
reduceAmountBy = oldData.reduceAmountBy,
isIgnoreReduce = oldData.isIgnoreReduce,

View file

@ -127,7 +127,6 @@ private fun ConstraintLayoutScope.SwapAmountBlock(
AmountBlockV2(
amountState = (amountUM.secondaryAmount.amountField as? AmountState.Data)?.copy(
title = resourceReference(R.string.send_with_swap_recipient_amount_title),
availableBalance = TextReference.EMPTY,
availableBalanceCrypto = TextReference.EMPTY,
) ?: amountUM.secondaryAmount.amountField,
isClickDisabled = true,

View file

@ -97,9 +97,7 @@ internal data object SwapAmountContentPreview {
val defaultState = SwapAmountUM.Content(
primaryAmount = SwapAmountFieldUM.Content(
amountType = SwapAmountType.From,
amountField = AmountStatePreviewData.amountState.copy(
availableBalance = stringReference("Balance: 100 BTC"),
),
amountField = AmountStatePreviewData.amountState,
title = stringReference("Tether"),
subtitleLeft = stringReference("11 101,123123456 BTC"),
subtitleRight = stringReference(" ${StringsSigns.DOT} 1 212,12 $"),
@ -112,7 +110,6 @@ internal data object SwapAmountContentPreview {
amountType = SwapAmountType.To,
amountField = AmountStatePreviewData.amountState.copy(
title = stringReference("Amount to receive"),
availableBalance = TextReference.EMPTY,
),
title = stringReference("Shiba Inu"),
priceImpact = stringReference("(-10%)"),

View file

@ -390,7 +390,7 @@ fun Token(
textAlign = TextAlign.Center,
modifier = Modifier
.defaultMinSize(minWidth = TangemTheme.dimens.size80)
.testTag(SwapTokenScreenTestTags.TOKEN_NAME),
.testTag(SwapTokenScreenTestTags.TOKEN_SYMBOL),
)
}
}

View file

@ -21,6 +21,7 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor
companion object {
val loadingUM = TangemPayTxHistoryUM.Loading(isBalanceHidden = true)
val emptyUM = TangemPayTxHistoryUM.Empty(isBalanceHidden = true)
val errorUM = TangemPayTxHistoryUM.Error(isBalanceHidden = true, onReload = {})
val contentUM = TangemPayTxHistoryUM.Content(
isBalanceHidden = false,
loadMore = { false },
@ -34,11 +35,35 @@ internal class PreviewTangemPayTxHistoryComponent(txHistoryUM: TangemPayTxHistor
amount = "-4.99 USD",
amountColor = { TangemTheme.colors.text.primary1 },
time = "16:41",
title = stringReference("Starbucks"),
title = stringReference("StarbucksStarbucksStarbucksStarbucks"),
subtitle = stringReference("Food&Drinks"),
iconUrl = null,
),
),
TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction(
transaction = TangemPayTransactionState.Content.Payment(
id = "signiferumque",
amount = "-126.20 USD",
amountColor = { TangemTheme.colors.text.primary1 },
time = "12:04",
onClick = {},
title = stringReference("Wallmart"),
subtitle = stringReference("Supermarket"),
isIncome = false,
),
),
TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction(
transaction = TangemPayTransactionState.Content.Payment(
id = "signiferumque",
amount = "+126.20 USD",
amountColor = { TangemTheme.colors.text.accent },
time = "12:04",
onClick = {},
title = stringReference("Wallmart"),
subtitle = stringReference("Supermarket"),
isIncome = true,
),
),
TangemPayTxHistoryUM.TangemPayTxHistoryItemUM.Transaction(
transaction = TangemPayTransactionState.Content.Spend(
id = "signiferumque",

View file

@ -0,0 +1,29 @@
package com.tangem.features.tangempay.entity
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tangempay.details.impl.R
internal sealed class TangemPayEmptyTransactionHistoryState {
abstract val iconRes: Int
abstract val text: TextReference
data class FailedToLoad(
private val onReload: () -> Unit,
) : TangemPayEmptyTransactionHistoryState() {
override val iconRes: Int = R.drawable.ic_alert_history_64
override val text: TextReference = resourceReference(R.string.transaction_history_error_failed_to_load)
val actionButtonConfig = ActionButtonConfig(
text = resourceReference(R.string.common_reload),
iconResId = R.drawable.ic_refresh_24,
onClick = onReload,
)
}
data object Empty : TangemPayEmptyTransactionHistoryState() {
override val iconRes: Int = R.drawable.ic_empty_token_64
override val text: TextReference = resourceReference(R.string.transaction_history_empty_transactions)
}
}

View file

@ -54,11 +54,16 @@ internal class TangemPayTxHistoryModel @Inject constructor(
.onEach(::updateState)
.launchIn(modelScope)
listManager.paginationStatus
.onEach { paginationStatus -> handlePaginationStatus(paginationStatus) }
.onEach(::handlePaginationStatus)
.launchIn(modelScope)
listManager.emptyStatus
.onEach(::handleEmptyState)
.launchIn(modelScope)
}
private fun updateState(items: ImmutableList<TangemPayTxHistoryUM.TangemPayTxHistoryItemUM>) {
if (items.isEmpty()) return // fast exit. If items is empty, no need to update ui items
uiState.update { state ->
if (state is TangemPayTxHistoryUM.Content) {
state.copy(items = items)
@ -72,6 +77,12 @@ internal class TangemPayTxHistoryModel @Inject constructor(
}
}
private fun handleEmptyState(isEmpty: Boolean) {
if (isEmpty) {
uiState.update { getEmptyState(it.isBalanceHidden) }
}
}
private fun handlePaginationStatus(status: PaginationStatus<*>) {
uiState.update { state ->
when (status) {
@ -108,6 +119,10 @@ internal class TangemPayTxHistoryModel @Inject constructor(
Timber.d("onTransactionClick: $item")
}
private fun getEmptyState(isBalanceHidden: Boolean): TangemPayTxHistoryUM.Empty {
return TangemPayTxHistoryUM.Empty(isBalanceHidden = isBalanceHidden)
}
private fun getErrorState(isBalanceHidden: Boolean): TangemPayTxHistoryUM.Error {
return TangemPayTxHistoryUM.Error(isBalanceHidden = isBalanceHidden, onReload = ::reload)
}

View file

@ -8,6 +8,7 @@ import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.features.tangempay.entity.TangemPayTransactionState
import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isPositive
import org.joda.time.DateTimeZone
@ -25,14 +26,23 @@ internal class TangemPayTxHistoryItemsConverter(
private fun convertSpend(spend: TangemPayTxHistoryItem.Spend): TangemPayTransactionState.Content.Spend {
val localDate = spend.date.withZone(DateTimeZone.getDefault())
val amount = spend.amount.format {
val amountPrefix = when (spend.status) {
TangemPayTxHistoryItem.Status.DECLINED -> ""
else -> StringsSigns.MINUS
}
val amount = amountPrefix + spend.amount.format {
fiat(fiatCurrencyCode = spend.currency.currencyCode, fiatCurrencySymbol = spend.currency.symbol)
}
return TangemPayTransactionState.Content.Spend(
id = spend.id,
onClick = { txHistoryUiActions.onTransactionClick(spend) },
amount = amount,
amountColor = { TangemTheme.colors.text.primary1 },
amountColor = {
when (spend.status) {
TangemPayTxHistoryItem.Status.DECLINED -> TangemTheme.colors.text.warning
else -> TangemTheme.colors.text.primary1
}
},
title = stringReference(spend.enrichedMerchantName ?: spend.merchantName),
subtitle = stringReference(spend.enrichedMerchantCategory ?: spend.merchantCategory),
time = DateTimeFormatters.formatDate(localDate, DateTimeFormatters.timeFormatter),
@ -41,7 +51,8 @@ internal class TangemPayTxHistoryItemsConverter(
}
private fun convertPayment(payment: TangemPayTxHistoryItem.Payment): TangemPayTransactionState.Content.Payment {
val amount = payment.amount.format {
val amountPrefix = if (payment.amount.isPositive()) StringsSigns.PLUS else StringsSigns.MINUS
val amount = amountPrefix + payment.amount.format {
fiat(fiatCurrencyCode = payment.currency.currencyCode, fiatCurrencySymbol = payment.currency.symbol)
}
val title = if (payment.amount.isPositive()) "Deposit" else "Withdrawal"
@ -64,7 +75,7 @@ internal class TangemPayTxHistoryItemsConverter(
}
private fun convertFee(fee: TangemPayTxHistoryItem.Fee): TangemPayTransactionState.Content.Fee {
val amount = fee.amount.format {
val amount = StringsSigns.MINUS + fee.amount.format {
fiat(fiatCurrencyCode = fee.currency.currencyCode, fiatCurrencySymbol = fee.currency.symbol)
}
return TangemPayTransactionState.Content.Fee(

View file

@ -2,12 +2,7 @@ package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -15,12 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -44,21 +35,14 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.orMaskWithStars
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.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.TokenDetailsTopBarTestTags
import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent
import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.entity.*
import com.tangem.utils.StringsSigns.DASH_SIGN
import kotlinx.collections.immutable.persistentListOf
@ -415,8 +399,8 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi
)
}
@Preview(device = Devices.PIXEL_7_PRO, group = "day")
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO, group = "night")
@Preview(device = Devices.PIXEL_7_PRO)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO)
@Composable
private fun TangemPayDetailsScreenPreview(
@PreviewParameter(TangemPayDetailsUMProvider::class) state: TangemPayDetailsUM,
@ -473,4 +457,26 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
isBalanceHidden = false,
),
),
)
@Preview(device = Devices.PIXEL_7_PRO)
@Composable
private fun TangemPayDetailsTxHistoryScreenPreview(
@PreviewParameter(TangemPayDetailsTxHistoryProvider::class) state: TangemPayTxHistoryUM,
) {
TangemThemePreview {
TangemPayDetailsScreen(
state = TangemPayDetailsUMProvider().values.first(),
txHistoryComponent = PreviewTangemPayTxHistoryComponent(txHistoryUM = state),
)
}
}
private class TangemPayDetailsTxHistoryProvider : CollectionPreviewParameterProvider<TangemPayTxHistoryUM>(
collection = listOf(
PreviewTangemPayTxHistoryComponent.loadingUM,
PreviewTangemPayTxHistoryComponent.contentUM,
PreviewTangemPayTxHistoryComponent.emptyUM,
PreviewTangemPayTxHistoryComponent.errorUM,
),
)

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ChainStyle
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
@ -27,6 +28,7 @@ import coil.compose.rememberAsyncImagePainter
import com.tangem.core.ui.R
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.buttons.actions.ActionButton
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.transactions.TxHistoryGroupTitle
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
@ -34,7 +36,9 @@ import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.EmptyTransactionBlockTestTags
import com.tangem.core.ui.test.TransactionHistoryBlockTestTags
import com.tangem.features.tangempay.entity.TangemPayEmptyTransactionHistoryState
import com.tangem.features.tangempay.entity.TangemPayTransactionState
import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM
@ -43,12 +47,25 @@ private const val LOAD_ITEMS_BUFFER = 20
internal fun LazyListScope.tangemPayTxHistoryItems(listState: LazyListState, state: TangemPayTxHistoryUM) {
when (state) {
is TangemPayTxHistoryUM.Content -> contentItems(listState = listState, state = state)
is TangemPayTxHistoryUM.Empty -> TODO("[REDACTED_JIRA]")
is TangemPayTxHistoryUM.Error -> TODO("[REDACTED_JIRA]")
is TangemPayTxHistoryUM.Empty -> nonContentItem(state = TangemPayEmptyTransactionHistoryState.Empty)
is TangemPayTxHistoryUM.Error -> nonContentItem(
state = TangemPayEmptyTransactionHistoryState.FailedToLoad(onReload = state.onReload),
)
is TangemPayTxHistoryUM.Loading -> loadingItems(state = state)
}
}
private fun LazyListScope.nonContentItem(state: TangemPayEmptyTransactionHistoryState, modifier: Modifier = Modifier) {
item(key = state::class.java, contentType = state::class.java) {
TangemPayEmptyTransactionBlock(
state = state,
modifier = modifier
.padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing12)
.fillMaxWidth(),
)
}
}
private fun LazyListScope.contentItems(listState: LazyListState, state: TangemPayTxHistoryUM.Content) {
itemsIndexed(
items = state.items,
@ -224,7 +241,7 @@ private fun TangemPayTransaction(
bottom.linkTo(timestampItem.top)
start.linkTo(titleItem.end)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
width = Dimension.preferredWrapContent
},
)
@ -346,7 +363,7 @@ private fun Amount(state: TangemPayTransactionState, isBalanceHidden: Boolean, m
}
is TangemPayTransactionState.Loading -> {
RectangleShimmer(
modifier = modifier.size(width = TangemTheme.dimens.size40, height = TangemTheme.dimens.size12),
modifier = modifier.size(width = TangemTheme.dimens.size72, height = TangemTheme.dimens.size12),
)
}
}
@ -370,4 +387,49 @@ private fun Timestamp(state: TangemPayTransactionState, modifier: Modifier = Mod
)
}
}
}
@Composable
private fun TangemPayEmptyTransactionBlock(
state: TangemPayEmptyTransactionHistoryState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(color = TangemTheme.colors.background.primary)
.padding(vertical = TangemTheme.dimens.spacing24)
.testTag(EmptyTransactionBlockTestTags.BLOCK),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size64)
.testTag(EmptyTransactionBlockTestTags.ICON),
painter = painterResource(id = state.iconRes),
tint = TangemTheme.colors.icon.inactive,
contentDescription = null,
)
Text(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing32)
.testTag(EmptyTransactionBlockTestTags.TEXT),
textAlign = TextAlign.Center,
text = state.text.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
when (state) {
is TangemPayEmptyTransactionHistoryState.Empty -> Unit
is TangemPayEmptyTransactionHistoryState.FailedToLoad -> ActionButton(
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth(),
config = state.actionButtonConfig,
)
}
}
}

View file

@ -33,6 +33,7 @@ internal class TangemPayTxHistoryListManager(
private val uiManager = TangemPayTxHistoryUiManager(state = state, txHistoryUiActions = txHistoryUiActions)
val uiItems: Flow<ImmutableList<TangemPayTxHistoryUM.TangemPayTxHistoryItemUM>> = uiManager.items
val emptyStatus: Flow<Boolean> = state.map { it.isEmpty }.distinctUntilChanged()
val paginationStatus: Flow<PaginationStatus<*>> = state.map { it.status }.distinctUntilChanged()
suspend fun launchPagination() = coroutineScope {
@ -80,6 +81,7 @@ internal class TangemPayTxHistoryListManager(
newCurrencyBatches = batchListState.data,
clearUiBatches = clearUiBatches,
),
isEmpty = batchListState.status is PaginationStatus.EndOfPagination && batchListState.data.isEmpty(),
)
}
}

View file

@ -7,4 +7,5 @@ import com.tangem.pagination.PaginationStatus
internal data class TangemPayTxHistoryState(
val status: PaginationStatus<*> = PaginationStatus.None,
val uiBatches: List<Batch<Int, List<TangemPayTxHistoryUM.TangemPayTxHistoryItemUM>>> = listOf(),
val isEmpty: Boolean = false,
)

View file

@ -56,7 +56,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
repository.getCustomerInfo()
.onRight { customerInfo ->
when {
!customerInfo.isKycApproved() -> {
!customerInfo.isKycApproved -> {
when (params) {
is TangemPayOnboardingComponent.Params.Deeplink ->
screenState.value = screenState.value.copy(fullScreenLoading = false)

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