Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-04 12:47:51 +03:00
commit ff24b00af4
404 changed files with 12634 additions and 3818 deletions

View file

@ -5,9 +5,8 @@ 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.res.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase
@ -48,8 +47,11 @@ internal class ArchivedAccountListModel @Inject constructor(
)
messageSender.send(
DialogMessage(
title = stringReference(account.accountName.value),
message = TextReference.EMPTY,
title = resourceReference(R.string.account_archived_recover_dialog_title),
message = resourceReference(
id = R.string.account_archived_recover_dialog_description,
formatArgs = wrappedList(account.accountName.value),
),
firstActionBuilder = { firstAction },
secondActionBuilder = { secondAction },
),

View file

@ -1,8 +1,8 @@
package com.tangem.features.account.createedit
import com.tangem.common.ui.account.toDomain
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.common.ui.account.toDomain
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -170,10 +170,14 @@ internal class AccountCreateEditModel @Inject constructor(
it.updateDerivationIndex(derivationIndex = derivationIndex.value)
}
}
.onLeft {
.onLeft { cause ->
handleError(
error = AccountFeatureError.CreateAccount.UnableToGetDerivationIndex,
params = mapOf("userWalletId" to userWalletId.stringValue),
message = cause.toString(),
params = mapOf(
"userWalletId" to userWalletId.stringValue,
"cause" to cause.toString(),
),
)
return@launch
@ -181,8 +185,12 @@ internal class AccountCreateEditModel @Inject constructor(
}
}
private fun handleError(error: AccountFeatureError, params: Map<String, String> = mapOf()) {
val exception = IllegalStateException(error.toString())
private fun handleError(
error: AccountFeatureError,
message: String? = null,
params: Map<String, String> = mapOf(),
) {
val exception = IllegalStateException("$error. Cause: $message")
Timber.e(exception)

View file

@ -5,7 +5,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList
data class AccountCreateEditUM(
internal data class AccountCreateEditUM(
val title: TextReference,
val account: Account,
val colorsState: Colors,

View file

@ -2,7 +2,7 @@ package com.tangem.features.account.details.entity
import com.tangem.common.ui.account.CryptoPortfolioIconUM
data class AccountDetailsUM(
internal data class AccountDetailsUM(
val accountName: String,
val accountIcon: CryptoPortfolioIconUM,
val onCloseClick: () -> Unit,

View file

@ -111,7 +111,6 @@ internal class AskBiometryModel @Inject constructor(
private suspend fun handleSuccessAllowing(userWallet: UserWallet) {
walletsRepository.saveShouldSaveUserWallets(item = true)
settingsRepository.setShouldSaveAccessCodes(value = true)
if (hotWalletFeatureToggles.isHotWalletEnabled) {
walletsRepository.setUseBiometricAuthentication(value = true)
@ -120,6 +119,7 @@ internal class AskBiometryModel @Inject constructor(
isBiometricsRequestPolicy = walletsRepository.requireAccessCode().not(),
)
} else {
settingsRepository.setShouldSaveAccessCodes(value = true)
if (userWallet is UserWallet.Cold) {
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = userWallet.hasAccessCode,

View file

@ -23,11 +23,11 @@ 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.core.wallets.UserWalletsListRepository
import com.tangem.domain.core.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.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.createwalletselection.entity.CreateWalletSelectionUM
@ -56,7 +56,7 @@ internal class CreateWalletSelectionModel @Inject constructor(
private val saveWalletUseCase: SaveWalletUseCase,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
@ -146,7 +146,7 @@ internal class CreateWalletSelectionModel @Inject constructor(
)
}
private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
@ -154,7 +154,7 @@ internal class CreateWalletSelectionModel @Inject constructor(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListManager.walletsCount.toString(),
walletsCount = userWalletsListRepository.userWalletsSync().size.toString(),
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)

View file

@ -11,16 +11,15 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.CardInfo
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.details.component.DetailsComponent
@ -56,7 +55,7 @@ internal class DetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val appStateHolder: ReduxStateHolder,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val feedbackFeatureToggles: FeedbackFeatureToggles,
@ -121,20 +120,15 @@ internal class DetailsModel @Inject constructor(
val selectedUserWallet = getSelectedWalletSyncUseCase().getOrNull()
?: error("Selected wallet is null")
if (selectedUserWallet is UserWallet.Hot) {
return@launch // TODO [REDACTED_TASK_KEY] [Hot Wallet] Send feedback
}
val scanResponse = selectedUserWallet.requireColdWallet().scanResponse
val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch
val metaInfo = getWalletMetaInfoUseCase(selectedUserWallet.walletId).getOrNull() ?: return@launch
val feedbackType = when {
userWallets.all { it is UserWallet.Cold && it.scanResponse.card.isVisa } ->
FeedbackEmailType.Visa.DirectUserRequest(cardInfo)
FeedbackEmailType.Visa.DirectUserRequest(metaInfo)
userWallets.all { it !is UserWallet.Cold || it.scanResponse.card.isVisa.not() } ->
FeedbackEmailType.DirectUserRequest(cardInfo)
FeedbackEmailType.DirectUserRequest(metaInfo)
else -> {
showFeedbackEmailTypeOptionBS(cardInfo)
showFeedbackEmailTypeOptionBS(metaInfo)
return@launch
}
}
@ -144,18 +138,14 @@ internal class DetailsModel @Inject constructor(
}
private fun openUseDesk() {
val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null")
if (userWallet is UserWallet.Hot) {
return // TODO [REDACTED_TASK_KEY] [Hot Wallet] UseDesk
modelScope.launch {
val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null")
val metaInfo = getWalletMetaInfoUseCase.invoke(userWallet.walletId).getOrNull() ?: return@launch
router.push(AppRoute.Usedesk(metaInfo))
}
val cardInfo = getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return
router.push(AppRoute.Usedesk(cardInfo))
}
private fun showFeedbackEmailTypeOptionBS(selectedCardInfo: CardInfo) {
private fun showFeedbackEmailTypeOptionBS(selectedWalletMetaInfo: WalletMetaInfo) {
state.update {
it.copy(
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig(
@ -171,7 +161,7 @@ internal class DetailsModel @Inject constructor(
content = SelectEmailFeedbackTypeBS(
onOptionClick = { option ->
onEmailFeedbackTypeOptionSelected(
selectedCardInfo = selectedCardInfo,
selectedWalletMetaInfo = selectedWalletMetaInfo,
option = option,
)
@ -189,32 +179,33 @@ internal class DetailsModel @Inject constructor(
}
private fun onEmailFeedbackTypeOptionSelected(
selectedCardInfo: CardInfo,
selectedWalletMetaInfo: WalletMetaInfo,
option: SelectEmailFeedbackTypeBS.Option,
) {
modelScope.launch {
val feedbackType = when (option) {
SelectEmailFeedbackTypeBS.Option.General -> {
if (selectedCardInfo.isVisa.not()) {
FeedbackEmailType.DirectUserRequest(selectedCardInfo)
if (selectedWalletMetaInfo.isVisa == false) {
FeedbackEmailType.DirectUserRequest(selectedWalletMetaInfo)
} else {
val scanResponse = getWalletsUseCase.invokeSync()
.firstOrNull { it is UserWallet.Cold && it.scanResponse.card.isVisa.not() }
?.requireColdWallet()?.scanResponse ?: return@launch
val userWallet = getWalletsUseCase.invokeSync()
.firstOrNull {
it is UserWallet.Hot || it is UserWallet.Cold && it.scanResponse.card.isVisa.not()
} ?: return@launch
val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch
FeedbackEmailType.DirectUserRequest(cardInfo)
val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch
FeedbackEmailType.DirectUserRequest(metaInfo)
}
}
SelectEmailFeedbackTypeBS.Option.Visa -> {
if (selectedCardInfo.isVisa) {
FeedbackEmailType.Visa.DirectUserRequest(selectedCardInfo)
if (selectedWalletMetaInfo.isVisa == true) {
FeedbackEmailType.Visa.DirectUserRequest(selectedWalletMetaInfo)
} else {
val scanResponse = getWalletsUseCase.invokeSync()
val userWallet = getWalletsUseCase.invokeSync()
.firstOrNull { it is UserWallet.Cold && it.scanResponse.card.isVisa }
?.requireColdWallet()?.scanResponse ?: return@launch
val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch
FeedbackEmailType.Visa.DirectUserRequest(cardInfo)
?: return@launch
val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch
FeedbackEmailType.Visa.DirectUserRequest(metaInfo)
}
}
}

View file

@ -26,6 +26,7 @@ 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.core.wallets.UserWalletsListRepository
import com.tangem.domain.core.wallets.error.SaveWalletError
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.redux.ReduxStateHolder
@ -41,6 +42,7 @@ import com.tangem.features.home.api.HomeComponent
import com.tangem.features.home.impl.ui.state.HomeUM
import com.tangem.features.home.impl.ui.state.Stories
import com.tangem.features.home.impl.ui.state.getRestrictedStories
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@ -76,6 +78,8 @@ internal class HomeModel @Inject constructor(
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
private val userWalletsListManager: UserWalletsListManager,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val userWalletsListRepository: UserWalletsListRepository,
private val reduxStateHolder: ReduxStateHolder,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
@ -216,7 +220,7 @@ internal class HomeModel @Inject constructor(
)
}
private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
@ -224,13 +228,21 @@ internal class HomeModel @Inject constructor(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListManager.walletsCount.toString(),
walletsCount = getWalletsCount().toString(),
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
}
private suspend fun getWalletsCount(): Int {
return if (hotWalletFeatureToggles.isHotWalletEnabled) {
userWalletsListRepository.userWalletsSync().size
} else {
userWalletsListManager.walletsCount
}
}
private fun setLoading(isLoading: Boolean) {
_uiState.update { it.copy(scanInProgress = isLoading) }
}

View file

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

View file

@ -74,7 +74,7 @@ internal fun AccessCode(state: AccessCodeUM, modifier: Modifier = Modifier) {
) {
PinTextField(
length = state.accessCodeLength,
isPasswordVisual = !state.isConfirmMode,
isPasswordVisual = state.isConfirmMode,
value = state.accessCode,
pinTextColor = PinTextColor.Primary,
onValueChange = state.onAccessCodeChange,

View file

@ -2,9 +2,15 @@ package com.tangem.features.hotwallet.addexistingwallet.entry
import com.arkivanov.decompose.router.stack.*
import com.tangem.common.routing.AppRoute
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.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.ShouldAskPermissionUseCase
import com.tangem.features.hotwallet.addexistingwallet.entry.routing.AddExistingWalletRoute
@ -26,6 +32,7 @@ internal class AddExistingWalletModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
val hotWalletStepperComponentModelCallback = HotWalletStepperComponentModelCallback()
@ -69,13 +76,31 @@ internal class AddExistingWalletModel @Inject constructor(
stackNavigation.replaceAll(AddExistingWalletRoute.SetupFinished)
}
private fun showSkipAccessCodeWarningDialog() {
uiMessageSender.send(
DialogMessage(
message = resourceReference(R.string.access_code_alert_skip_description),
title = resourceReference(R.string.access_code_alert_skip_title),
firstAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
),
secondAction = EventMessageAction(
title = resourceReference(R.string.access_code_alert_skip_ok),
onClick = { navigateToPushNotificationsOrNext() },
),
dismissOnFirstAction = true,
),
)
}
inner class HotWalletStepperComponentModelCallback : HotWalletStepperComponent.ModelCallback {
override fun onBackClick() {
onChildBack()
}
override fun onSkipClick() {
navigateToPushNotificationsOrNext()
showSkipAccessCodeWarningDialog()
}
}

View file

@ -23,11 +23,11 @@ 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.core.wallets.UserWalletsListRepository
import com.tangem.domain.core.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.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.hotwallet.addexistingwallet.start.entity.AddExistingWalletStartUM
@ -56,7 +56,7 @@ internal class AddExistingWalletStartModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val urlOpener: UrlOpener,
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
@ -145,7 +145,7 @@ internal class AddExistingWalletStartModel @Inject constructor(
)
}
private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
@ -153,7 +153,7 @@ internal class AddExistingWalletStartModel @Inject constructor(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListManager.walletsCount.toString(),
walletsCount = userWalletsListRepository.userWalletsSync().size.toString(),
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)

View file

@ -62,7 +62,7 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi
)
OptionBlock(
modifier = Modifier
.padding(top = 24.dp),
.padding(top = 32.dp),
backgroundColor = TangemTheme.colors.background.secondary,
title = stringResourceSafe(R.string.wallet_import_seed_title),
description = stringResourceSafe(R.string.wallet_import_seed_description),
@ -71,6 +71,8 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi
enabled = true,
)
OptionBlock(
modifier = Modifier
.padding(top = 8.dp),
backgroundColor = TangemTheme.colors.background.secondary,
title = stringResourceSafe(R.string.wallet_import_scan_title),
description = stringResourceSafe(R.string.wallet_import_scan_description),
@ -99,6 +101,8 @@ internal fun AddExistingWalletStartContent(state: AddExistingWalletStartUM, modi
enabled = true,
)
OptionBlock(
modifier = Modifier
.padding(top = 8.dp),
backgroundColor = TangemTheme.colors.background.secondary,
title = stringResourceSafe(R.string.wallet_import_google_drive_title),
description = stringResourceSafe(R.string.wallet_import_google_drive_description),

View file

@ -1,6 +1,5 @@
package com.tangem.features.hotwallet.common.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -8,8 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@ -30,38 +29,16 @@ internal fun OptionBlock(
backgroundColor: Color,
modifier: Modifier = Modifier,
) {
val backgroundColor by animateColorAsState(
targetValue = if (enabled) {
backgroundColor
} else {
backgroundColor.copy(alpha = DISABLED_COLORS_ALPHA)
},
)
val titleColor by animateColorAsState(
targetValue = if (enabled) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.primary1.copy(alpha = DISABLED_COLORS_ALPHA)
},
)
val descriptionColor by animateColorAsState(
targetValue = if (enabled) {
TangemTheme.colors.text.tertiary
} else {
TangemTheme.colors.text.tertiary.copy(alpha = DISABLED_COLORS_ALPHA)
},
)
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 8.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.alpha(if (enabled) 1f else DISABLED_COLORS_ALPHA)
.background(
color = backgroundColor,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.conditional(onClick != null) {
.conditional(onClick != null && enabled) {
onClick?.let { clickableSingle(onClick = it) } ?: Modifier
}
.padding(16.dp),
@ -73,7 +50,7 @@ internal fun OptionBlock(
.padding(end = 4.dp),
text = title,
style = TangemTheme.typography.subtitle1,
color = titleColor,
color = TangemTheme.colors.text.primary1,
)
badge?.invoke()
}
@ -82,7 +59,7 @@ internal fun OptionBlock(
.padding(top = 4.dp),
text = description,
style = TangemTheme.typography.body2,
color = descriptionColor,
color = TangemTheme.colors.text.tertiary,
)
}
}

View file

@ -1,14 +1,10 @@
package com.tangem.features.hotwallet.manualbackup.phrase.entity
import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal data class ManualBackupPhraseUM(
val onContinueClick: () -> Unit,
val words: ImmutableList<MnemonicGridItem> = persistentListOf(),
) {
data class MnemonicGridItem(
val index: Int,
val mnemonic: String,
)
}
val words: ImmutableList<EnumeratedTwoColumnGridItem> = persistentListOf(),
)

View file

@ -5,6 +5,7 @@ import arrow.core.getOrElse
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent
@ -51,7 +52,7 @@ internal class ManualBackupPhraseModel @Inject constructor(
uiState.update {
it.copy(
words = seedPhrasePrivateInfo.mnemonic.mnemonicComponents.mapIndexed { index, s ->
ManualBackupPhraseUM.MnemonicGridItem(index + 1, s)
EnumeratedTwoColumnGridItem(index + 1, s)
}.toImmutableList(),
)
}

View file

@ -7,20 +7,18 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid
import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.hotwallet.impl.R
import com.tangem.features.hotwallet.manualbackup.phrase.entity.ManualBackupPhraseUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
@ -41,8 +39,8 @@ internal fun ManualBackupPhraseContent(state: ManualBackupPhraseUM, modifier: Mo
modifier = Modifier.padding(top = 20.dp),
)
SeedPhraseGridBlock(
mnemonicGridItems = state.words,
EnumeratedTwoColumnGrid(
items = state.words,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp, bottom = 32.dp),
@ -97,70 +95,6 @@ private fun TitleBlock(state: ManualBackupPhraseUM, modifier: Modifier = Modifie
}
}
@Composable
private fun SeedPhraseGridBlock(
mnemonicGridItems: ImmutableList<ManualBackupPhraseUM.MnemonicGridItem>,
modifier: Modifier = Modifier,
) {
VerticalGrid(
modifier = modifier,
items = mnemonicGridItems,
) { item ->
Row(
modifier = Modifier.padding(all = TangemTheme.dimens.size8),
verticalAlignment = Alignment.CenterVertically,
) {
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
} else {
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
}
@Composable
private inline fun <T> VerticalGrid(
items: ImmutableList<T>,
modifier: Modifier = Modifier,
crossinline content: @Composable (T) -> Unit,
) {
val columnLength = items.size / 2
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
repeat(2) { index ->
Column {
for (i in 0 until columnLength) {
val item = items[index * columnLength + i]
content(item)
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Preview(showBackground = true, widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -170,7 +104,7 @@ private fun Preview() {
state = ManualBackupPhraseUM(
onContinueClick = {},
words = List(12) {
ManualBackupPhraseUM.MnemonicGridItem(
EnumeratedTwoColumnGridItem(
index = it + 1,
mnemonic = "word${it + 1}",
)

View file

@ -0,0 +1,39 @@
package com.tangem.features.hotwallet.upgradewallet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.hotwallet.UpgradeWalletComponent
import com.tangem.features.hotwallet.upgradewallet.ui.UpgradeWalletContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
internal class DefaultUpgradeWalletComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: UpgradeWalletComponent.Params,
) : UpgradeWalletComponent, AppComponentContext by context {
private val model: UpgradeWalletModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
UpgradeWalletContent(
state = state,
modifier = modifier,
)
}
@AssistedFactory
interface Factory : UpgradeWalletComponent.Factory {
override fun create(
context: AppComponentContext,
params: UpgradeWalletComponent.Params,
): DefaultUpgradeWalletComponent
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.hotwallet.upgradewallet
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.navigation.url.UrlOpener
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.features.hotwallet.upgradewallet.entity.UpgradeWalletUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
internal class UpgradeWalletModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val urlOpener: UrlOpener,
) : Model() {
internal val uiState: StateFlow<UpgradeWalletUM>
field = MutableStateFlow(
UpgradeWalletUM(
onBackClick = { router.pop() },
onBuyTangemWalletClick = ::onBuyTangemWalletClick,
onScanDeviceClick = ::onScanDeviceClick,
),
)
private fun onBuyTangemWalletClick() {
modelScope.launch {
generateBuyTangemCardLinkUseCase.invoke().let { urlOpener.openUrl(it) }
}
}
private fun onScanDeviceClick() {
// TODO [REDACTED_TASK_KEY]
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.hotwallet.upgradewallet.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.UpgradeWalletComponent
import com.tangem.features.hotwallet.upgradewallet.DefaultUpgradeWalletComponent
import com.tangem.features.hotwallet.upgradewallet.UpgradeWalletModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
internal interface UpgradeWalletModule {
@Binds
fun bindUpgradeWalletComponentFactory(impl: DefaultUpgradeWalletComponent.Factory): UpgradeWalletComponent.Factory
@Binds
@IntoMap
@ClassKey(UpgradeWalletModel::class)
fun bindUpgradeWalletModel(model: UpgradeWalletModel): Model
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.hotwallet.upgradewallet.entity
internal data class UpgradeWalletUM(
val onBackClick: () -> Unit,
val onBuyTangemWalletClick: () -> Unit,
val onScanDeviceClick: () -> Unit,
)

View file

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

View file

@ -4,10 +4,16 @@ import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.push
import com.arkivanov.decompose.router.stack.replaceAll
import com.tangem.core.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.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.ShouldAskPermissionUseCase
import com.tangem.features.hotwallet.manualbackup.check.ManualBackupCheckComponent
@ -32,6 +38,7 @@ internal class WalletActivationModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
val params = paramsContainer.require<WalletActivationComponent.Params>()
@ -79,13 +86,31 @@ internal class WalletActivationModel @Inject constructor(
stackNavigation.replaceAll(WalletActivationRoute.SetupFinished)
}
private fun showSkipAccessCodeWarningDialog() {
uiMessageSender.send(
DialogMessage(
message = resourceReference(R.string.access_code_alert_skip_description),
title = resourceReference(R.string.access_code_alert_skip_title),
firstAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
),
secondAction = EventMessageAction(
title = resourceReference(R.string.access_code_alert_skip_ok),
onClick = { navigateToPushNotificationsOrNext() },
),
dismissOnFirstAction = true,
),
)
}
inner class HotWalletStepperComponentModelCallback : HotWalletStepperComponent.ModelCallback {
override fun onBackClick() {
onChildBack()
}
override fun onSkipClick() {
navigateToPushNotificationsOrNext()
showSkipAccessCodeWarningDialog()
}
}

View file

@ -4,10 +4,12 @@ import com.tangem.core.ui.components.label.entity.LabelUM
internal data class WalletBackupUM(
val onBackClick: () -> Unit,
val recoveryPhraseStatus: LabelUM?,
val googleDriveStatus: LabelUM?,
val recoveryPhraseOption: LabelUM?,
val googleDriveOption: LabelUM?,
val googleDriveStatus: BackupStatus,
val onRecoveryPhraseClick: () -> Unit,
val onGoogleDriveClick: () -> Unit,
val onHardwareWalletClick: () -> Unit,
val backedUp: Boolean,
)

View file

@ -20,6 +20,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.common.routing.AppRoute
import com.tangem.features.hotwallet.WalletBackupComponent
import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus
import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
@ -40,16 +41,18 @@ internal class WalletBackupModel @Inject constructor(
field = MutableStateFlow(
WalletBackupUM(
onBackClick = { router.pop() },
recoveryPhraseStatus = LabelUM(
recoveryPhraseOption = LabelUM(
text = resourceReference(R.string.hw_backup_no_backup),
style = LabelStyle.WARNING,
),
googleDriveStatus = LabelUM(
googleDriveOption = LabelUM(
text = resourceReference(R.string.common_coming_soon),
style = LabelStyle.REGULAR,
),
googleDriveStatus = BackupStatus.ComingSoon,
onRecoveryPhraseClick = ::onRecoveryPhraseClick,
onGoogleDriveClick = { },
onHardwareWalletClick = ::onHardwareWalletClick,
backedUp = false,
),
)
@ -95,7 +98,7 @@ internal class WalletBackupModel @Inject constructor(
}
private fun WalletBackupUM.updateBackupStatusesHotWallet(userWallet: UserWallet.Hot): WalletBackupUM = copy(
recoveryPhraseStatus = if (userWallet.backedUp) {
recoveryPhraseOption = if (userWallet.backedUp) {
LabelUM(
text = resourceReference(R.string.common_done),
style = LabelStyle.ACCENT,
@ -106,7 +109,7 @@ internal class WalletBackupModel @Inject constructor(
style = LabelStyle.WARNING,
)
},
googleDriveStatus = LabelUM(
googleDriveOption = LabelUM(
text = resourceReference(R.string.common_coming_soon),
style = LabelStyle.REGULAR,
),
@ -120,4 +123,8 @@ internal class WalletBackupModel @Inject constructor(
uiMessageSender.send(makeBackupAtFirstAlertBS)
}
}
private fun onHardwareWalletClick() {
router.push(AppRoute.UpgradeWallet(params.userWalletId))
}
}

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@ -25,6 +26,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.rows.NetworkTitle
import com.tangem.core.ui.extensions.resourceReference
@OptIn(ExperimentalMaterial3Api::class)
@ -47,11 +49,12 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod
.padding(horizontal = 16.dp),
) {
OptionBlock(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.padding(top = 8.dp),
title = stringResourceSafe(R.string.hw_backup_seed_title),
description = stringResourceSafe(R.string.hw_backup_seed_description),
badge = {
state.recoveryPhraseStatus?.let { Label(it) }
state.recoveryPhraseOption?.let { Label(it) }
},
onClick = state.onRecoveryPhraseClick,
enabled = true,
@ -59,16 +62,37 @@ internal fun WalletBackupContent(state: WalletBackupUM, modifier: Modifier = Mod
)
OptionBlock(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.padding(top = 8.dp),
title = stringResourceSafe(R.string.hw_backup_google_drive_title),
description = stringResourceSafe(R.string.hw_backup_google_drive_description),
badge = {
state.googleDriveStatus?.let { Label(it) }
state.googleDriveOption?.let { Label(it) }
},
onClick = state.onGoogleDriveClick,
enabled = state.googleDriveStatus != BackupStatus.ComingSoon,
backgroundColor = TangemTheme.colors.background.primary,
)
NetworkTitle(
title = {
Text(
modifier = Modifier,
text = stringResourceSafe(R.string.express_provider_recommended),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
)
OptionBlock(
modifier = Modifier.fillMaxWidth(),
title = stringResourceSafe(R.string.hw_backup_hardware_title),
description = stringResourceSafe(R.string.hw_backup_hardware_description),
badge = null,
onClick = state.onHardwareWalletClick,
enabled = true,
backgroundColor = TangemTheme.colors.background.primary,
)
}
}
}
@ -85,45 +109,51 @@ private fun WalletBackupContentPreview(@PreviewParameter(WalletBackupUMProvider:
private class WalletBackupUMProvider : CollectionPreviewParameterProvider<WalletBackupUM>(
collection = listOf(
WalletBackupUM(
recoveryPhraseStatus = LabelUM(
recoveryPhraseOption = LabelUM(
text = resourceReference(R.string.hw_backup_no_backup),
style = LabelStyle.WARNING,
),
googleDriveStatus = LabelUM(
googleDriveOption = LabelUM(
text = resourceReference(R.string.common_coming_soon),
style = LabelStyle.REGULAR,
),
googleDriveStatus = BackupStatus.ComingSoon,
onBackClick = {},
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
),
WalletBackupUM(
recoveryPhraseStatus = LabelUM(
recoveryPhraseOption = LabelUM(
text = resourceReference(R.string.hw_backup_no_backup),
style = LabelStyle.WARNING,
),
googleDriveStatus = LabelUM(
googleDriveOption = LabelUM(
text = resourceReference(R.string.hw_backup_no_backup),
style = LabelStyle.WARNING,
),
googleDriveStatus = BackupStatus.NoBackup,
onBackClick = {},
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
),
WalletBackupUM(
recoveryPhraseStatus = LabelUM(
recoveryPhraseOption = LabelUM(
text = resourceReference(R.string.common_done),
style = LabelStyle.ACCENT,
),
googleDriveStatus = LabelUM(
googleDriveOption = LabelUM(
text = resourceReference(R.string.common_done),
style = LabelStyle.ACCENT,
),
googleDriveStatus = BackupStatus.Done,
onBackClick = {},
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
),
),

View file

@ -2,12 +2,11 @@ package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.wallet.UserWalletId
interface AddCustomTokenComponent : ComposableBottomSheetComponent {
data class Params(
val userWalletId: UserWalletId,
val mode: AddCustomTokenMode,
val source: ManageTokensSource,
val onDismiss: () -> Unit,
val onCurrencyAdded: () -> Unit,

View file

@ -7,9 +7,16 @@ import com.tangem.domain.models.wallet.UserWalletId
interface ManageTokensComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId?,
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

@ -1,8 +1,22 @@
package com.tangem.features.managetokens.component
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
enum class ManageTokensSource(val analyticsName: String) {
STORIES(analyticsName = "Stories"),
ONBOARDING(analyticsName = "Onboarding"),
SETTINGS(analyticsName = "Settings"),
SEND_VIA_SWAP(analyticsName = "SendViaSwap"),
}
sealed interface ManageTokensMode {
data class Wallet(val userWalletId: UserWalletId) : ManageTokensMode
data class Account(val accountId: AccountId) : ManageTokensMode
data object None : ManageTokensMode
}
sealed interface AddCustomTokenMode {
data class Wallet(val userWalletId: UserWalletId) : AddCustomTokenMode
data class Account(val accountId: AccountId) : AddCustomTokenMode
}

View file

@ -22,6 +22,7 @@ import com.tangem.features.managetokens.choosetoken.entity.ChooseManageTokensBot
import com.tangem.features.managetokens.choosetoken.entity.ChooseManagedTokenUM
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent.Source
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.component.analytics.CommonManageTokensAnalyticEvents
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
@ -29,6 +30,7 @@ import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.list.ManageTokensListManager
import com.tangem.features.managetokens.utils.list.ManageTokensUseCasesFacade
import com.tangem.features.managetokens.utils.list.getLoadingItems
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
@ -51,12 +53,19 @@ internal class ChooseManagedTokensModel @Inject constructor(
private val setShouldShowNotificationUseCase: SetShouldShowNotificationUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer,
manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory,
manageTokensListManagerFactory: ManageTokensListManager.Factory,
) : Model() {
private val params: ChooseManagedTokensComponent.Params = paramsContainer.require()
private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory
.create(mode = ManageTokensMode.None)
private val manageTokensListManager = manageTokensListManagerFactory.create(
scope = modelScope,
source = ManageTokensSource.SEND_VIA_SWAP,
mode = ManageTokensMode.None,
useCasesFacade = useCasesFacade,
onCurrencySelect = { token ->
bottomSheetNavigation.activate(
ChooseManageTokensBottomSheetConfig.SwapTokensBottomSheetConfig(
@ -86,7 +95,7 @@ internal class ChooseManagedTokensModel @Inject constructor(
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(source = ManageTokensSource.SEND_VIA_SWAP, userWalletId = null)
manageTokensListManager.launchPagination()
}
}
@ -159,7 +168,7 @@ internal class ChooseManagedTokensModel @Inject constructor(
}
}
.sample(periodMillis = 1_000)
.onEach { query -> manageTokensListManager.search(userWalletId = null, query = query) }
.onEach { query -> manageTokensListManager.search(query = query) }
.launchIn(modelScope)
}
@ -295,7 +304,7 @@ internal class ChooseManagedTokensModel @Inject constructor(
if (state.readContent.isInitialBatchLoading || state.readContent.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(userWalletId = null, query = state.readContent.search.query)
manageTokensListManager.loadMore(query = state.readContent.search.query)
}
return true

View file

@ -2,13 +2,12 @@ package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableDialogComponent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
internal interface CustomTokenDerivationInputComponent : ComposableDialogComponent {
data class Params(
val userWalletId: UserWalletId,
val mode: AddCustomTokenMode,
val onConfirm: (SelectedDerivationPath) -> Unit,
val onDismiss: () -> Unit,
)

View file

@ -2,7 +2,6 @@ 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
import com.tangem.features.managetokens.entity.customtoken.CustomTokenFormValues
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
@ -10,7 +9,7 @@ import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
internal interface CustomTokenFormComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val mode: AddCustomTokenMode,
val network: SelectedNetwork,
val derivationPath: SelectedDerivationPath?,
val formValues: CustomTokenFormValues,

View file

@ -2,7 +2,6 @@ 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
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.entity.customtoken.SelectedNetwork
@ -11,13 +10,13 @@ internal interface CustomTokenSelectorComponent : ComposableContentComponent {
sealed class Params {
data class NetworkSelector(
val userWalletId: UserWalletId,
val mode: AddCustomTokenMode,
val selectedNetwork: SelectedNetwork?,
val onNetworkSelected: (SelectedNetwork) -> Unit,
) : Params()
data class DerivationPathSelector(
val userWalletId: UserWalletId,
val mode: AddCustomTokenMode,
val selectedNetwork: SelectedNetwork,
val selectedDerivationPath: SelectedDerivationPath?,
val onDerivationPathSelected: (SelectedDerivationPath) -> Unit,

View file

@ -38,7 +38,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor(
) : AddCustomTokenComponent, AppComponentContext by context {
private val initialConfiguration = AddCustomTokenConfig(
userWalletId = params.userWalletId,
mode = params.mode,
step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR,
)
@ -105,7 +105,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor(
selectorComponentFactory.create(
context = childByContext(componentContext),
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = config.userWalletId,
mode = config.mode,
selectedNetwork = null,
onNetworkSelected = ::changeSelectedNetwork,
),
@ -115,7 +115,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor(
selectorComponentFactory.create(
context = childByContext(componentContext),
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = config.userWalletId,
mode = config.mode,
selectedNetwork = config.selectedNetwork,
onNetworkSelected = ::changeSelectedNetwork,
),
@ -125,7 +125,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor(
selectorComponentFactory.create(
context = childByContext(componentContext),
params = CustomTokenSelectorComponent.Params.DerivationPathSelector(
userWalletId = config.userWalletId,
mode = config.mode,
selectedNetwork = requireNotNull(config.selectedNetwork) {
"Network is not selected"
},
@ -138,7 +138,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor(
formComponentFactory.create(
context = childByContext(componentContext),
params = CustomTokenFormComponent.Params(
userWalletId = config.userWalletId,
mode = config.mode,
network = requireNotNull(config.selectedNetwork) {
"Network is not selected"
},

View file

@ -41,7 +41,7 @@ internal class DefaultCustomTokenSelectorComponent @AssistedInject constructor(
is CustomTokenSelectorDialogConfig.CustomDerivationInput -> customTokenDerivationInputComponentFactory.create(
context = childByContext(context),
params = CustomTokenDerivationInputComponent.Params(
userWalletId = config.userWalletId,
mode = config.mode,
onConfirm = model::selectCustomDerivationPath,
onDismiss = model.dialogNavigation::dismiss,
),

View file

@ -13,6 +13,7 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.AddCustomTokenMode
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig
import com.tangem.features.managetokens.model.ManageTokensModel
@ -52,18 +53,20 @@ internal class DefaultManageTokensComponent @AssistedInject constructor(
private fun bottomSheetChild(
config: ManageTokensBottomSheetConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is ManageTokensBottomSheetConfig.AddCustomToken -> {
addCustomTokenComponentFactory.create(
context = childByContext(componentContext),
params = AddCustomTokenComponent.Params(
userWalletId = config.userWalletId,
source = params.source,
onDismiss = model.bottomSheetNavigation::dismiss,
onCurrencyAdded = model::reloadList,
),
)
): ComposableBottomSheetComponent {
val mode = when (config) {
is ManageTokensBottomSheetConfig.AddWalletCustomToken -> AddCustomTokenMode.Wallet(config.userWalletId)
is ManageTokensBottomSheetConfig.AddAccountCustomToken -> AddCustomTokenMode.Account(config.accountId)
}
return addCustomTokenComponentFactory.create(
context = childByContext(componentContext),
params = AddCustomTokenComponent.Params(
mode = mode,
source = params.source,
onDismiss = model.bottomSheetNavigation::dismiss,
onCurrencyAdded = model::reloadList,
),
)
}
@AssistedFactory

View file

@ -6,6 +6,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.AddCustomTokenMode
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig
import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet
@ -14,7 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
internal class PreviewAddCustomTokenComponent(
initialState: AddCustomTokenConfig = AddCustomTokenConfig(
userWalletId = UserWalletId(stringValue = "321"),
mode = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")),
step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR,
),
) : AddCustomTokenComponent {
@ -41,7 +42,7 @@ internal class PreviewAddCustomTokenComponent(
AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR -> {
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = config.userWalletId,
mode = config.mode,
selectedNetwork = null,
onNetworkSelected = {},
),
@ -50,7 +51,7 @@ internal class PreviewAddCustomTokenComponent(
AddCustomTokenConfig.Step.NETWORK_SELECTOR -> {
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = config.userWalletId,
mode = config.mode,
selectedNetwork = config.selectedNetwork,
onNetworkSelected = {},
),
@ -59,7 +60,7 @@ internal class PreviewAddCustomTokenComponent(
AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR -> {
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.DerivationPathSelector(
userWalletId = config.userWalletId,
mode = config.mode,
selectedNetwork = config.selectedNetwork!!,
selectedDerivationPath = config.selectedDerivationPath!!,
onDerivationPathSelected = {},

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenMode
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params
import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM
@ -18,7 +19,7 @@ import kotlinx.collections.immutable.toImmutableList
internal class PreviewCustomTokenSelectorComponent(
private val params: Params = Params.NetworkSelector(
userWalletId = UserWalletId(stringValue = "321"),
mode = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")),
selectedNetwork = null,
onNetworkSelected = {},
),

View file

@ -13,6 +13,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM
@ -38,8 +39,10 @@ internal class PreviewManageTokensComponent(
value = ManageTokensUM.ManageContent(
popBack = {},
items = items,
topBar = if (params.userWalletId != null) {
ManageTokensTopBarUM.ManageContent(
topBar = when (params.mode) {
is ManageTokensMode.Account,
is ManageTokensMode.Wallet,
-> ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = {},
endButton = TopAppBarButtonUM.Icon(
@ -47,8 +50,7 @@ internal class PreviewManageTokensComponent(
onClicked = {},
),
)
} else {
ManageTokensTopBarUM.ReadContent(
ManageTokensMode.None -> ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_search_tokens),
onBackButtonClick = {},
)

View file

@ -1,13 +1,13 @@
package com.tangem.features.managetokens.entity.customtoken
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenMode
import kotlinx.serialization.Serializable
@Serializable
internal data class AddCustomTokenConfig(
val step: Step,
val userWalletId: UserWalletId,
val mode: AddCustomTokenMode,
val selectedNetwork: SelectedNetwork? = null,
val selectedDerivationPath: SelectedDerivationPath? = null,
val formValues: CustomTokenFormValues = CustomTokenFormValues(),

View file

@ -1,6 +1,6 @@
package com.tangem.features.managetokens.entity.customtoken
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenMode
import kotlinx.serialization.Serializable
@Serializable
@ -8,6 +8,6 @@ internal sealed class CustomTokenSelectorDialogConfig {
@Serializable
data class CustomDerivationInput(
val userWalletId: UserWalletId,
val mode: AddCustomTokenMode,
) : CustomTokenSelectorDialogConfig()
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.managetokens.entity.managetokens
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
@ -7,7 +8,12 @@ import kotlinx.serialization.Serializable
internal sealed class ManageTokensBottomSheetConfig {
@Serializable
data class AddCustomToken(
data class AddWalletCustomToken(
val userWalletId: UserWalletId,
) : ManageTokensBottomSheetConfig()
@Serializable
data class AddAccountCustomToken(
val accountId: AccountId,
) : ManageTokensBottomSheetConfig()
}

View file

@ -12,9 +12,6 @@ import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase
import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase
import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent
import com.tangem.features.managetokens.component.CustomTokenFormComponent
import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM
@ -25,6 +22,7 @@ import com.tangem.features.managetokens.entity.customtoken.TextInputFieldUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.CustomCurrencyFormBuilder
import com.tangem.features.managetokens.utils.CustomCurrencyValidator
import com.tangem.features.managetokens.utils.list.CustomTokenFormUseCasesFacade
import com.tangem.features.managetokens.utils.mapper.mapToDomainModel
import com.tangem.features.managetokens.utils.ui.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -40,18 +38,17 @@ import javax.inject.Inject
@ModelScoped
internal class CustomTokenFormModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val customCurrencyValidator: CustomCurrencyValidator,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase,
private val messageSender: UiMessageSender,
private val customTokenFormManager: CustomCurrencyFormBuilder,
private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer,
customTokenFormUseCasesFacadeFactory: CustomTokenFormUseCasesFacade.Factory,
) : Model() {
private val params: CustomTokenFormComponent.Params = paramsContainer.require()
private var createdCurrency: CryptoCurrency? = null
private var useCasesFacade: CustomTokenFormUseCasesFacade = customTokenFormUseCasesFacadeFactory.create(params.mode)
private val customCurrencyValidator = CustomCurrencyValidator(useCasesFacade)
val state: MutableStateFlow<CustomTokenFormUM> = MutableStateFlow(
value = getInitialState(),
@ -117,7 +114,6 @@ internal class CustomTokenFormModel @Inject constructor(
.drop(count = 1) // Skip initial state
.onEach { formValues ->
customCurrencyValidator.validateForm(
userWalletId = params.userWalletId,
networkId = params.network.id,
derivationPath = getDerivationPath(),
formValues = formValues,
@ -156,7 +152,6 @@ internal class CustomTokenFormModel @Inject constructor(
private fun validatePrefilledForm() = modelScope.launch {
customCurrencyValidator.validateForm(
userWalletId = params.userWalletId,
networkId = params.network.id,
derivationPath = getDerivationPath(),
formValues = state.value.tokenForm.mapToDomainModel(),
@ -169,8 +164,7 @@ internal class CustomTokenFormModel @Inject constructor(
isAlreadyAdded: Boolean,
isCustom: Boolean,
) = modelScope.launch {
val needToAddDerivation = hasMissedDerivationsUseCase(
userWalletId = params.userWalletId,
val needToAddDerivation = useCasesFacade.hasMissedDerivationsUseCase(
networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value),
)
@ -343,13 +337,13 @@ internal class CustomTokenFormModel @Inject constructor(
)
analyticsEventHandler.send(event)
derivePublicKeysUseCase(params.userWalletId, listOf(currency)).getOrElse {
useCasesFacade.derivePublicKeysUseCase(listOf(currency)).getOrElse {
Timber.e(it, "Failed to derive public keys")
showErrorDialog()
return@resource
}
addCryptoCurrenciesUseCase(params.userWalletId, currency).getOrElse {
useCasesFacade.addCryptoCurrenciesUseCase(currency).getOrElse {
Timber.e(it, "Failed to add currency")
showErrorDialog()
return@resource

View file

@ -11,7 +11,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.GetSupportedNetworksUseCase
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenMode
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.DerivationPathSelector
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent.Params.NetworkSelector
@ -83,7 +83,7 @@ internal class CustomTokenSelectorModel @Inject constructor(
}
private suspend fun loadNetworks(selector: NetworkSelector): List<SelectableItemUM> {
return getSupportedNetworks(selector.userWalletId).map { network ->
return getSupportedNetworks(selector.mode).map { network ->
network.toCurrencyNetworkModel(
isSelected = network.id == selector.selectedNetwork?.id,
onSelectedStateChange = {
@ -122,7 +122,7 @@ internal class CustomTokenSelectorModel @Inject constructor(
derivationPaths.add(defaultPath)
}
getSupportedNetworks(selector.userWalletId)
getSupportedNetworks(selector.mode)
.mapNotNullTo(derivationPaths) { network ->
if (network.id == selector.selectedNetwork.id) {
return@mapNotNullTo null // Skip default path
@ -146,8 +146,9 @@ internal class CustomTokenSelectorModel @Inject constructor(
return derivationPaths
}
private suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> {
return getSupportedNetworksUseCase(userWalletId).getOrElse { e ->
private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List<Network> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e ->
val message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error))
messageSender.send(message)
@ -158,7 +159,7 @@ internal class CustomTokenSelectorModel @Inject constructor(
private fun showCustomDerivationInput() {
val config = when (params) {
is NetworkSelector -> return
is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput(params.userWalletId)
is DerivationPathSelector -> CustomTokenSelectorDialogConfig.CustomDerivationInput(params.mode)
}
dialogNavigation.activate(config)

View file

@ -17,12 +17,10 @@ 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.managetokens.SaveManagedTokensUseCase
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase
import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig
import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM
@ -30,6 +28,7 @@ import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.list.ChangedCurrencies
import com.tangem.features.managetokens.utils.list.ManageTokensListManager
import com.tangem.features.managetokens.utils.list.ManageTokensUseCasesFacade
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -47,18 +46,24 @@ internal class ManageTokensModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val messageSender: UiMessageSender,
private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase,
private val saveManagedTokensUseCase: SaveManagedTokensUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
manageTokensListManagerFactory: ManageTokensListManager.Factory,
manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory,
paramsContainer: ParamsContainer,
) : Model() {
private val params: ManageTokensComponent.Params = paramsContainer.require()
private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory
.create(mode = params.mode)
private val manageTokensListManager = manageTokensListManagerFactory.create()
private val manageTokensListManager = manageTokensListManagerFactory.create(
scope = modelScope,
source = params.source,
mode = params.mode,
useCasesFacade = useCasesFacade,
)
val state: MutableStateFlow<ManageTokensUM> = MutableStateFlow(getInitialState(params.userWalletId))
val state: MutableStateFlow<ManageTokensUM> = MutableStateFlow(getInitialState())
val bottomSheetNavigation: SlotNavigation<ManageTokensBottomSheetConfig> = SlotNavigation()
init {
@ -79,23 +84,24 @@ internal class ManageTokensModel @Inject constructor(
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(source = params.source, userWalletId = params.userWalletId)
manageTokensListManager.launchPagination()
}
}
fun reloadList() {
modelScope.launch {
manageTokensListManager.reload(params.userWalletId)
manageTokensListManager.reload()
}
}
private fun getInitialState(userWalletId: UserWalletId?): ManageTokensUM {
private fun getInitialState(): ManageTokensUM {
analyticsEventHandler.send(ManageTokensAnalyticEvent.ScreenOpened(params.source))
return if (userWalletId == null) {
createReadContentModel()
} else {
createManageContentModel()
return when (params.mode) {
is ManageTokensMode.Wallet,
is ManageTokensMode.Account,
-> createManageContentModel()
ManageTokensMode.None -> createReadContentModel()
}
}
@ -164,7 +170,7 @@ internal class ManageTokensModel @Inject constructor(
}
}
.sample(periodMillis = 1_000)
.onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) }
.onEach { query -> manageTokensListManager.search(query = query) }
.launchIn(modelScope)
}
@ -261,19 +267,16 @@ internal class ManageTokensModel @Inject constructor(
private fun updateChangedItems(currenciesToAdd: ChangedCurrencies, currenciesToRemove: ChangedCurrencies) {
modelScope.launch {
val hasMissedDerivations = params.userWalletId?.let { walletId ->
val networks = currenciesToAdd.values
.flatten()
.toSet()
.associate { it.backendId to null }
hasMissedDerivationsUseCase(walletId, networks)
}
val networks = currenciesToAdd.values
.flatten()
.toSet()
.associate { it.backendId to null }
val hasMissedDerivations = useCasesFacade.hasMissedDerivationsUseCase(networks)
state.update { state ->
state.copySealed(
hasChanges = currenciesToAdd.isNotEmpty() || currenciesToRemove.isNotEmpty(),
needToAddDerivations = hasMissedDerivations ?: false,
needToAddDerivations = hasMissedDerivations,
)
}
}
@ -284,7 +287,7 @@ internal class ManageTokensModel @Inject constructor(
if (state.isInitialBatchLoading || state.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query)
manageTokensListManager.loadMore(query = state.search.query)
}
return true
@ -292,9 +295,14 @@ internal class ManageTokensModel @Inject constructor(
private fun navigateToAddCustomToken() {
analyticsEventHandler.send(CustomTokenAnalyticsEvent.ButtonCustomToken(params.source))
params.userWalletId?.let {
bottomSheetNavigation.activate(ManageTokensBottomSheetConfig.AddCustomToken(it))
when (val portfolio = params.mode) {
is ManageTokensMode.Wallet ->
bottomSheetNavigation
.activate(ManageTokensBottomSheetConfig.AddWalletCustomToken(portfolio.userWalletId))
is ManageTokensMode.Account ->
bottomSheetNavigation
.activate(ManageTokensBottomSheetConfig.AddAccountCustomToken(portfolio.accountId))
ManageTokensMode.None -> Unit
}
}
@ -308,8 +316,7 @@ internal class ManageTokensModel @Inject constructor(
)
analyticsEventHandler.send(event)
saveManagedTokensUseCase(
userWalletId = requireNotNull(params.userWalletId),
useCasesFacade.saveManagedTokensUseCase(
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).getOrElse {

View file

@ -13,11 +13,10 @@ 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.managetokens.SaveManagedTokensUseCase
import com.tangem.domain.redux.OnboardingManageTokensAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
@ -25,6 +24,7 @@ import com.tangem.features.managetokens.entity.managetokens.OnboardingManageToke
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.list.ChangedCurrencies
import com.tangem.features.managetokens.utils.list.ManageTokensListManager
import com.tangem.features.managetokens.utils.list.ManageTokensUseCasesFacade
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -42,15 +42,22 @@ internal class OnboardingManageTokensModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val messageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
private val saveManagedTokensUseCase: SaveManagedTokensUseCase,
private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
manageTokensListManagerFactory: ManageTokensListManager.Factory,
manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory,
paramsContainer: ParamsContainer,
) : Model() {
private val params: OnboardingManageTokensComponent.Params = paramsContainer.require()
private val manageTokensListManager = manageTokensListManagerFactory.create()
private val portfolio = ManageTokensMode.Wallet(params.userWalletId)
private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory
.create(mode = portfolio)
private val manageTokensListManager = manageTokensListManagerFactory.create(
scope = modelScope,
source = ManageTokensSource.ONBOARDING,
useCasesFacade = useCasesFacade,
mode = portfolio,
)
val state: MutableStateFlow<OnboardingManageTokensUM> = MutableStateFlow(getInitialState())
val returnToParentComponentFlow = MutableSharedFlow<Unit>()
@ -73,10 +80,7 @@ internal class OnboardingManageTokensModel @Inject constructor(
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(
source = ManageTokensSource.ONBOARDING,
userWalletId = params.userWalletId,
)
manageTokensListManager.launchPagination()
}
}
@ -119,7 +123,7 @@ internal class OnboardingManageTokensModel @Inject constructor(
}
}
.sample(periodMillis = 1_000)
.onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) }
.onEach { query -> manageTokensListManager.search(query = query) }
.launchIn(modelScope)
}
@ -208,13 +212,11 @@ internal class OnboardingManageTokensModel @Inject constructor(
)
}
} else {
val hasMissedDerivations = hasMissedDerivationsUseCase.invoke(
userWalletId = params.userWalletId,
networksWithDerivationPath = currenciesToAdd.values
.flatten()
.toSet()
.associate { it.backendId to null },
)
val network = currenciesToAdd.values
.flatten()
.toSet()
.associate { it.backendId to null }
val hasMissedDerivations = useCasesFacade.hasMissedDerivationsUseCase(network = network)
state.update { state ->
state.copy(
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Continue(
@ -231,7 +233,7 @@ internal class OnboardingManageTokensModel @Inject constructor(
if (state.isInitialBatchLoading || state.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query)
manageTokensListManager.loadMore(query = state.search.query)
}
return true
@ -255,8 +257,7 @@ internal class OnboardingManageTokensModel @Inject constructor(
)
analyticsEventHandler.send(event)
saveManagedTokensUseCase(
userWalletId = requireNotNull(params.userWalletId),
useCasesFacade.saveManagedTokensUseCase(
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).getOrElse {
@ -281,8 +282,7 @@ internal class OnboardingManageTokensModel @Inject constructor(
) {
analyticsEventHandler.send(ManageTokensAnalyticEvent.ButtonLater)
saveManagedTokensUseCase(
userWalletId = requireNotNull(params.userWalletId),
useCasesFacade.saveManagedTokensUseCase(
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).getOrElse {

View file

@ -19,6 +19,7 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.AddCustomTokenMode
import com.tangem.features.managetokens.component.preview.PreviewAddCustomTokenComponent
import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenConfig
import com.tangem.features.managetokens.entity.customtoken.AddCustomTokenUM
@ -68,12 +69,13 @@ private fun Preview_AddCustomTokenBottomSheet(
}
private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider<AddCustomTokenComponent> {
private val mode: AddCustomTokenMode get() = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321"))
override val values: Sequence<AddCustomTokenComponent>
get() = sequenceOf(
PreviewAddCustomTokenComponent(),
PreviewAddCustomTokenComponent(
initialState = AddCustomTokenConfig(
userWalletId = UserWalletId(stringValue = "321"),
mode = mode,
step = AddCustomTokenConfig.Step.FORM,
selectedNetwork = SelectedNetwork(
id = Network.ID(value = "1", derivationPath = Network.DerivationPath.None),
@ -85,7 +87,7 @@ private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider<
),
PreviewAddCustomTokenComponent(
initialState = AddCustomTokenConfig(
userWalletId = UserWalletId(stringValue = "321"),
mode = mode,
step = AddCustomTokenConfig.Step.NETWORK_SELECTOR,
selectedNetwork = SelectedNetwork(
id = Network.ID(value = "0", derivationPath = Network.DerivationPath.None),
@ -97,7 +99,7 @@ private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider<
),
PreviewAddCustomTokenComponent(
initialState = AddCustomTokenConfig(
userWalletId = UserWalletId(stringValue = "321"),
mode = mode,
step = AddCustomTokenConfig.Step.DERIVATION_PATH_SELECTOR,
selectedDerivationPath = SelectedDerivationPath(
id = Network.ID(value = "0", derivationPath = Network.DerivationPath.None),

View file

@ -32,6 +32,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.component.AddCustomTokenMode
import com.tangem.features.managetokens.component.CustomTokenSelectorComponent
import com.tangem.features.managetokens.component.preview.PreviewCustomTokenSelectorComponent
import com.tangem.features.managetokens.entity.customtoken.CustomTokenSelectorUM
@ -268,12 +269,13 @@ private class CustomTokenNetworkSelectorComponentPreviewProvider :
PreviewParameterProvider<CustomTokenSelectorComponent> {
private val derivationPath = Network.DerivationPath.Card("m/44'/0'/0'/0/0")
private val mode: AddCustomTokenMode get() = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321"))
override val values: Sequence<CustomTokenSelectorComponent>
get() = sequenceOf(
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.DerivationPathSelector(
userWalletId = UserWalletId(stringValue = "321"),
mode = mode,
selectedNetwork = SelectedNetwork(
id = Network.ID(value = "0", derivationPath = derivationPath),
name = "Ethereum",
@ -291,7 +293,7 @@ private class CustomTokenNetworkSelectorComponentPreviewProvider :
),
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.NetworkSelector(
userWalletId = UserWalletId(stringValue = "321"),
mode = mode,
selectedNetwork = SelectedNetwork(
id = Network.ID(value = "0", derivationPath = derivationPath),
name = "Ethereum",

View file

@ -1,31 +1,21 @@
package com.tangem.features.managetokens.utils
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase
import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase
import com.tangem.domain.managetokens.FindTokenUseCase
import com.tangem.domain.managetokens.ValidateTokenFormUseCase
import com.tangem.domain.managetokens.model.AddCustomTokenForm
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.managetokens.model.exceptoin.FindTokenException
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.utils.list.CustomTokenFormUseCasesFacade
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveInAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
internal class CustomCurrencyValidator @Inject constructor(
private val validateTokenFormUseCase: ValidateTokenFormUseCase,
private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase,
private val findTokenUseCase: FindTokenUseCase,
private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase,
internal class CustomCurrencyValidator(
private val useCasesFacade: CustomTokenFormUseCasesFacade,
) {
private val validateFormJobHolder = JobHolder()
@ -45,14 +35,13 @@ internal class CustomCurrencyValidator @Inject constructor(
}
suspend fun validateForm(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
formValues: AddCustomTokenForm.Raw,
) = coroutineScope {
updateStatus(Status.Validating)
val result = validateTokenFormUseCase(
val result = useCasesFacade.validateTokenFormUseCase(
networkId = networkId,
formValues = formValues,
)
@ -73,20 +62,19 @@ internal class CustomCurrencyValidator @Inject constructor(
launch {
when (validatedForm) {
is AddCustomTokenForm.Validated.All -> {
findOrCreateCurrency(userWalletId, networkId, derivationPath, validatedForm)
findOrCreateCurrency(networkId, derivationPath, validatedForm)
}
is AddCustomTokenForm.Validated.ContractAddressOnly -> {
findToken(userWalletId, networkId, derivationPath, validatedForm)
findToken(networkId, derivationPath, validatedForm)
}
is AddCustomTokenForm.Validated.Empty -> {
createCurrency(userWalletId, networkId, derivationPath, validatedForm = null)
createCurrency(networkId, derivationPath, validatedForm = null)
}
}
}.saveInAndJoin(validateFormJobHolder)
}
private suspend fun findOrCreateCurrency(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
validatedForm: AddCustomTokenForm.Validated.All,
@ -96,14 +84,13 @@ internal class CustomCurrencyValidator @Inject constructor(
currentState.prevFoundOrCreatedCurrency.contractAddress == validatedForm.contractAddress
) {
// No need to search for token again if contract address is not changed
createCurrency(userWalletId, networkId, derivationPath, validatedForm)
createCurrency(networkId, derivationPath, validatedForm)
return
}
updateStatus(Status.SearchingToken)
val foundToken = findTokenUseCase(
userWalletId = userWalletId,
val foundToken = useCasesFacade.findTokenUseCase(
contractAddress = validatedForm.contractAddress,
networkId = networkId,
derivationPath = derivationPath,
@ -121,22 +108,20 @@ internal class CustomCurrencyValidator @Inject constructor(
}
if (foundToken != null) {
updateStateToValidated(userWalletId, foundToken, fillForm = true, isCustom = false)
updateStateToValidated(foundToken, fillForm = true, isCustom = false)
} else {
createCurrency(userWalletId, networkId, derivationPath, validatedForm)
createCurrency(networkId, derivationPath, validatedForm)
}
}
private suspend fun findToken(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
validatedForm: AddCustomTokenForm.Validated.ContractAddressOnly,
) {
updateStatus(Status.SearchingToken)
val token = findTokenUseCase(
userWalletId = userWalletId,
val token = useCasesFacade.findTokenUseCase(
contractAddress = validatedForm.contractAddress,
networkId = networkId,
derivationPath = derivationPath,
@ -155,17 +140,15 @@ internal class CustomCurrencyValidator @Inject constructor(
return
}
updateStateToValidated(userWalletId, token, fillForm = true, isCustom = false)
updateStateToValidated(token, fillForm = true, isCustom = false)
}
private suspend fun createCurrency(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
validatedForm: AddCustomTokenForm.Validated.All?,
) {
val currency = createCryptoCurrencyUseCase(
userWalletId = userWalletId,
val currency = useCasesFacade.createCryptoCurrencyUseCase(
networkId = networkId,
derivationPath = derivationPath,
formValues = validatedForm,
@ -175,20 +158,14 @@ internal class CustomCurrencyValidator @Inject constructor(
return
}
updateStateToValidated(userWalletId, currency, fillForm = false, isCustom = validatedForm != null)
updateStateToValidated(currency, fillForm = false, isCustom = validatedForm != null)
}
private suspend fun updateStateToValidated(
userWalletId: UserWalletId,
currency: CryptoCurrency,
fillForm: Boolean,
isCustom: Boolean,
) {
private suspend fun updateStateToValidated(currency: CryptoCurrency, fillForm: Boolean, isCustom: Boolean) {
val currentStatus = state.value.status
if (currentStatus is Status.Validated && currentStatus.currency == currency) return
val isNotAdded = checkIsCurrencyNotAddedUseCase(
userWalletId = userWalletId,
val isNotAdded = useCasesFacade.checkIsCurrencyNotAddedUseCase(
networkId = currency.network.id,
derivationPath = currency.network.derivationPath,
contractAddress = when (currency) {

View file

@ -0,0 +1,117 @@
package com.tangem.features.managetokens.utils.list
import arrow.core.Either
import arrow.core.NonEmptyList
import com.tangem.domain.managetokens.CheckIsCurrencyNotAddedUseCase
import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase
import com.tangem.domain.managetokens.FindTokenUseCase
import com.tangem.domain.managetokens.ValidateTokenFormUseCase
import com.tangem.domain.managetokens.model.AddCustomTokenForm
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.managetokens.model.exceptoin.FindTokenException
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.BackendId
import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase
import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase
import com.tangem.features.managetokens.component.AddCustomTokenMode
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("LongParameterList")
internal class CustomTokenFormUseCasesFacade @AssistedInject constructor(
private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val validateTokenFormUseCase: ValidateTokenFormUseCase,
private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase,
private val findTokenUseCase: FindTokenUseCase,
private val checkIsCurrencyNotAddedUseCase: CheckIsCurrencyNotAddedUseCase,
@Assisted private val mode: AddCustomTokenMode,
) {
suspend fun hasMissedDerivationsUseCase(networksWithDerivationPath: Map<BackendId, String?>): Boolean =
when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> hasMissedDerivationsUseCase.invoke(
userWalletId = mode.userWalletId,
networksWithDerivationPath = networksWithDerivationPath,
)
}
suspend fun addCryptoCurrenciesUseCase(currency: CryptoCurrency): Either<Throwable, Unit> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> addCryptoCurrenciesUseCase.invoke(
userWalletId = mode.userWalletId,
currency = currency,
)
}
suspend fun derivePublicKeysUseCase(currencies: List<CryptoCurrency>): Either<Throwable, Unit> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> derivePublicKeysUseCase.invoke(
userWalletId = mode.userWalletId,
currencies = currencies,
)
}
suspend fun checkIsCurrencyNotAddedUseCase(
networkId: Network.ID,
derivationPath: Network.DerivationPath,
contractAddress: String?,
): Either<Throwable, Boolean> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> checkIsCurrencyNotAddedUseCase.invoke(
userWalletId = mode.userWalletId,
networkId = networkId,
derivationPath = derivationPath,
contractAddress = contractAddress,
)
}
suspend fun createCryptoCurrencyUseCase(
networkId: Network.ID,
derivationPath: Network.DerivationPath,
formValues: AddCustomTokenForm.Validated.All?,
): Either<Throwable, CryptoCurrency> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> createCryptoCurrencyUseCase.invoke(
userWalletId = mode.userWalletId,
networkId = networkId,
derivationPath = derivationPath,
formValues = formValues,
)
}
suspend fun findTokenUseCase(
contractAddress: String,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): Either<FindTokenException, CryptoCurrency.Token> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> findTokenUseCase.invoke(
userWalletId = mode.userWalletId,
contractAddress = contractAddress,
networkId = networkId,
derivationPath = derivationPath,
)
}
suspend fun validateTokenFormUseCase(
networkId: Network.ID,
formValues: AddCustomTokenForm.Raw,
): Either<NonEmptyList<CustomTokenFormValidationException>, AddCustomTokenForm.Validated> = when (mode) {
is AddCustomTokenMode.Account -> TODO("Account")
is AddCustomTokenMode.Wallet -> validateTokenFormUseCase.invoke(
networkId = networkId,
formValues = formValues,
)
}
@AssistedFactory
interface Factory {
fun create(mode: AddCustomTokenMode): CustomTokenFormUseCasesFacade
}
}

View file

@ -9,13 +9,14 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.*
import com.tangem.domain.managetokens.model.*
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext
import com.tangem.domain.managetokens.model.ManageTokensUpdateAction
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.impl.R
@ -24,7 +25,6 @@ import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -42,37 +42,36 @@ import timber.log.Timber
@Suppress("LongParameterList", "LargeClass")
internal class ManageTokensListManager @AssistedInject constructor(
private val getManagedTokensUseCase: GetManagedTokensUseCase,
private val getDistinctManagedTokensUseCase: GetDistinctManagedCurrenciesUseCase,
private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase,
private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase,
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val messageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val clipboardManager: ClipboardManager,
manageTokensWarningDelegateFactory: ManageTokensWarningDelegate.Factory,
@Assisted private val useCasesFacade: ManageTokensUseCasesFacade,
@Assisted private val source: ManageTokensSource,
@Assisted private val mode: ManageTokensMode,
@Assisted private val scope: CoroutineScope,
@Assisted private val onCurrencySelect: (ManagedCryptoCurrency.Token) -> Unit = {},
) : ManageTokensUiActions {
private lateinit var scope: CoroutineScope
private lateinit var source: ManageTokensSource
private val jobHolder = JobHolder()
private val actionsFlow: MutableSharedFlow<ManageTokensBatchAction> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val state: MutableStateFlow<ManageTokensListState> = MutableStateFlow(ManageTokensListState())
private val state: MutableStateFlow<ManageTokensListState> =
MutableStateFlow(ManageTokensListState(mode = mode))
private val manageTokensWarningDelegate: ManageTokensWarningDelegate = manageTokensWarningDelegateFactory
.create(mode, source, this)
private val changedCurrenciesManager = ChangedCurrenciesManager()
private val uiManager = ManageTokensUiManager(
state = state,
messageSender = messageSender,
manageTokensWarningDelegate = manageTokensWarningDelegate,
dispatchers = dispatchers,
actions = this,
scopeProvider = Provider { scope },
sourceProvider = Provider { source },
scope = scope,
)
val currenciesToAdd: StateFlow<ChangedCurrencies> = changedCurrenciesManager.currenciesToAdd.asStateFlow()
@ -84,62 +83,61 @@ internal class ManageTokensListManager @AssistedInject constructor(
.distinctUntilChanged()
val uiItems: Flow<ImmutableList<CurrencyItemUM>> = uiManager.items
suspend fun launchPagination(source: ManageTokensSource, userWalletId: UserWalletId?) = coroutineScope {
scope = this
this@ManageTokensListManager.source = source
val batchFlow = getManagedTokensUseCase(
suspend fun launchPagination() = coroutineScope {
val loadUserTokensFromRemote = when (mode) {
is ManageTokensMode.Wallet -> source == ManageTokensSource.ONBOARDING
is ManageTokensMode.Account,
ManageTokensMode.None,
-> false
}
val batchFlow = useCasesFacade.getManagedTokensUseCase(
context = ManageTokensListBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = this,
),
// only for onboarding case, change carefully and check repository implementation
loadUserTokensFromRemote = userWalletId != null && source == ManageTokensSource.ONBOARDING,
loadUserTokensFromRemote = loadUserTokensFromRemote,
)
batchFlow.state
.onEach { state -> updateState(state, userWalletId) }
.onEach { state -> updateState(state) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
// Initial load
reload(userWalletId)
reload()
}
suspend fun reload(userWalletId: UserWalletId?) {
state.value = ManageTokensListState()
suspend fun reload() {
state.value = ManageTokensListState(mode = mode)
actionsFlow.emit(
BatchAction.Reload(
requestParams = ManageTokensListConfig(userWalletId, searchText = null),
requestParams = useCasesFacade.manageTokensListConfig(searchText = null),
),
)
}
suspend fun loadMore(userWalletId: UserWalletId?, query: String) {
suspend fun loadMore(query: String) {
actionsFlow.emit(
BatchAction.LoadMore(
requestParams = ManageTokensListConfig(userWalletId, query),
requestParams = useCasesFacade.manageTokensListConfig(query),
),
)
}
suspend fun search(userWalletId: UserWalletId?, query: String) {
state.value = ManageTokensListState(searchQuery = query)
suspend fun search(query: String) {
state.value = ManageTokensListState(mode = mode, searchQuery = query)
actionsFlow.emit(
BatchAction.Reload(
requestParams = ManageTokensListConfig(
userWalletId = userWalletId,
requestParams = useCasesFacade.manageTokensListConfig(
searchText = query,
),
),
)
}
private fun updateState(
batchListState: BatchListState<Int, List<ManagedCryptoCurrency>>,
userWalletId: UserWalletId?,
) {
private fun updateState(batchListState: BatchListState<Int, List<ManagedCryptoCurrency>>) {
state.update { state ->
state.copy(
status = batchListState.status,
@ -154,7 +152,6 @@ internal class ManageTokensListManager @AssistedInject constructor(
) {
state.update { state ->
state.copy(
userWalletId = userWalletId,
currencyBatches = emptyList(),
uiBatches = listOf(
Batch(
@ -170,7 +167,7 @@ internal class ManageTokensListManager @AssistedInject constructor(
scope.launch {
state.update { state ->
val newBatches = getDistinctManagedTokensUseCase(batchListState.data)
val newBatches = useCasesFacade.getDistinctManagedTokensUseCase(batchListState.data)
val currentBatches = state.currencyBatches
// Distinct until changed
@ -181,9 +178,13 @@ internal class ManageTokensListManager @AssistedInject constructor(
return@launch
}
val canEditItems = userWalletId != null
val canEditItems = when (state.mode) {
is ManageTokensMode.Account,
is ManageTokensMode.Wallet,
-> true
ManageTokensMode.None -> false
}
state.copy(
userWalletId = userWalletId,
currencyBatches = newBatches,
uiBatches = uiManager.createOrUpdateUiBatches(newBatches, canEditItems),
canEditItems = canEditItems,
@ -216,10 +217,10 @@ internal class ManageTokensListManager @AssistedInject constructor(
sendSelectCurrencyAnalyticsEvent(currency, isSelected = false)
}
override fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) {
override fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) {
scope.launch {
removeCustomCurrencyUseCase.invoke(userWalletId, currency)
.onRight { reload(userWalletId) }
useCasesFacade.removeCustomCurrencyUseCase(currency)
.onRight { reload() }
.onLeft { Timber.e(it) }
}
}
@ -258,9 +259,8 @@ internal class ManageTokensListManager @AssistedInject constructor(
actionsFlow.tryEmit(action)
}
override suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean {
return checkHasLinkedTokensUseCase(
userWalletId = userWalletId,
override suspend fun checkHasLinkedTokens(network: Network): Boolean {
return useCasesFacade.checkHasLinkedTokensUseCase(
network = network,
tempAddedTokens = changedCurrenciesManager.currenciesToAdd.value,
tempRemovedTokens = changedCurrenciesManager.currenciesToRemove.value,
@ -269,7 +269,7 @@ internal class ManageTokensListManager @AssistedInject constructor(
it,
"""
Failed to check linked tokens
|- User wallet ID: $userWalletId
|- Mode: $mode
|- Network ID: ${network.id}
""".trimIndent(),
)
@ -286,18 +286,16 @@ internal class ManageTokensListManager @AssistedInject constructor(
}
override suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
sourceNetwork: ManagedCryptoCurrency.SourceNetwork,
): CurrencyUnsupportedState? {
return checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
return useCasesFacade.checkCurrencyUnsupportedUseCase(
sourceNetwork = sourceNetwork,
).getOrElse {
Timber.e(
it,
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Mode: $mode
|- Source Network: $sourceNetwork
""".trimIndent(),
)
@ -359,8 +357,7 @@ internal class ManageTokensListManager @AssistedInject constructor(
if (currency !is ManagedCryptoCurrency.Token) return@launch
if (isSelected) {
val userWalletId = state.value.userWalletId
val unsupportedState = userWalletId?.let { checkCurrencyUnsupportedState(it, source) }
val unsupportedState = checkCurrencyUnsupportedState(source)
if (unsupportedState != null) {
showUnsupportedWarning(unsupportedState)
} else {
@ -368,7 +365,7 @@ internal class ManageTokensListManager @AssistedInject constructor(
}
} else {
if (checkNeedToShowRemoveNetworkWarning(currency, source.network)) {
showRemoveNetworkWarning(
manageTokensWarningDelegate.showRemoveNetworkWarning(
currency = currency,
network = source.network,
isCoin = source is ManagedCryptoCurrency.SourceNetwork.Main,
@ -404,67 +401,6 @@ internal class ManageTokensListManager @AssistedInject constructor(
messageSender.send(message)
}
private suspend fun showRemoveNetworkWarning(
currency: ManagedCryptoCurrency,
network: Network,
isCoin: Boolean,
onConfirm: () -> Unit,
) {
val userWalletId = state.value.userWalletId
val hasLinkedTokens = if (userWalletId == null || !isCoin) {
false
} else {
checkHasLinkedTokens(userWalletId, network)
}
val canHideWithoutConfirming = source == ManageTokensSource.ONBOARDING
if (hasLinkedTokens) {
showLinkedTokensWarning(currency, network)
} else if (canHideWithoutConfirming) {
onConfirm()
} else {
showHideTokenWarning(currency, onConfirm)
}
}
private fun showLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network) {
val message = DialogMessage(
title = resourceReference(
id = R.string.token_details_unable_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(
id = R.string.token_details_unable_hide_alert_message,
formatArgs = wrappedList(
currency.name,
currency.symbol,
network.name,
),
),
)
messageSender.send(message)
}
private fun showHideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit) {
val message = DialogMessage(
title = resourceReference(
id = R.string.token_details_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(R.string.token_details_hide_alert_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.token_details_hide_alert_hide),
warning = true,
onClick = onConfirm,
)
},
secondActionBuilder = { cancelAction() },
)
messageSender.send(message)
}
private fun Batch<Int, List<ManagedCryptoCurrency>>.currencyIndexById(id: ManagedCryptoCurrency.ID): Int {
return data
.indexOfFirst { it.id == id }
@ -474,6 +410,12 @@ internal class ManageTokensListManager @AssistedInject constructor(
@AssistedFactory
interface Factory {
fun create(onCurrencySelect: (ManagedCryptoCurrency.Token) -> Unit = {}): ManageTokensListManager
fun create(
scope: CoroutineScope,
mode: ManageTokensMode,
source: ManageTokensSource,
useCasesFacade: ManageTokensUseCasesFacade,
onCurrencySelect: (ManagedCryptoCurrency.Token) -> Unit = {},
): ManageTokensListManager
}
}

View file

@ -3,7 +3,7 @@ package com.tangem.features.managetokens.utils.list
import com.tangem.domain.managetokens.model.ManageTokensListConfig
import com.tangem.domain.managetokens.model.ManageTokensUpdateAction
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
@ -13,7 +13,7 @@ internal typealias ManageTokensBatchAction = BatchAction<Int, ManageTokensListCo
internal data class ManageTokensListState(
val status: PaginationStatus<*> = PaginationStatus.None,
val userWalletId: UserWalletId? = null,
val mode: ManageTokensMode,
val uiBatches: List<Batch<Int, List<CurrencyItemUM>>> = mutableListOf(),
val currencyBatches: List<Batch<Int, List<ManagedCryptoCurrency>>> = mutableListOf(),
val canEditItems: Boolean = true,

View file

@ -3,7 +3,6 @@ package com.tangem.features.managetokens.utils.list
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
internal interface ManageTokensUiActions {
@ -13,14 +12,13 @@ internal interface ManageTokensUiActions {
fun removeCurrency(batchKey: Int, currency: ManagedCryptoCurrency.Token, network: Network)
fun removeCustomCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom)
fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom)
fun checkNeedToShowRemoveNetworkWarning(currency: ManagedCryptoCurrency.Token, network: Network): Boolean
suspend fun checkHasLinkedTokens(userWalletId: UserWalletId, network: Network): Boolean
suspend fun checkHasLinkedTokens(network: Network): Boolean
suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
sourceNetwork: ManagedCryptoCurrency.SourceNetwork,
): CurrencyUnsupportedState?
}

View file

@ -1,19 +1,10 @@
package com.tangem.features.managetokens.utils.list
import com.tangem.core.decompose.ui.UiMessageSender
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.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.mapper.toUiModel
import com.tangem.features.managetokens.utils.ui.update
import com.tangem.pagination.Batch
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.collections.immutable.ImmutableList
@ -29,19 +20,12 @@ import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class ManageTokensUiManager(
private val state: MutableStateFlow<ManageTokensListState>,
private val messageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
private val scopeProvider: Provider<CoroutineScope>,
private val sourceProvider: Provider<ManageTokensSource>,
private val scope: CoroutineScope,
private val actions: ManageTokensUiActions,
private val manageTokensWarningDelegate: ManageTokensWarningDelegate,
) {
private val scope: CoroutineScope
get() = scopeProvider()
private val source: ManageTokensSource
get() = sourceProvider()
@OptIn(ExperimentalCoroutinesApi::class)
val items: Flow<ImmutableList<CurrencyItemUM>> = state
.mapLatest { state ->
@ -109,75 +93,11 @@ internal class ManageTokensUiManager(
}
private fun removeCustomCurrency(currency: ManagedCryptoCurrency.Custom) = scope.launch(dispatchers.default) {
showRemoveNetworkWarning(
manageTokensWarningDelegate.showRemoveNetworkWarning(
currency = currency,
network = currency.network,
isCoin = currency is ManagedCryptoCurrency.Custom.Coin,
onConfirm = {
val userWalletId = requireNotNull(state.value.userWalletId) { "UserWalletId is null. Can not remove" }
actions.removeCustomCurrency(userWalletId = userWalletId, currency = currency)
},
onConfirm = { actions.removeCustomCurrency(currency = currency) },
)
}
private suspend fun showRemoveNetworkWarning(
currency: ManagedCryptoCurrency,
network: Network,
isCoin: Boolean,
onConfirm: () -> Unit,
) {
val userWalletId = state.value.userWalletId
val hasLinkedTokens = if (userWalletId == null || !isCoin) {
false
} else {
actions.checkHasLinkedTokens(userWalletId, network)
}
val canHideWithoutConfirming = source == ManageTokensSource.ONBOARDING
if (hasLinkedTokens) {
showLinkedTokensWarning(currency, network)
} else if (canHideWithoutConfirming) {
onConfirm()
} else {
showHideTokenWarning(currency, onConfirm)
}
}
private fun showLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network) {
val message = DialogMessage(
title = resourceReference(
id = R.string.token_details_unable_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(
id = R.string.token_details_unable_hide_alert_message,
formatArgs = wrappedList(
currency.name,
currency.symbol,
network.name,
),
),
)
messageSender.send(message)
}
private fun showHideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit) {
val message = DialogMessage(
title = resourceReference(
id = R.string.token_details_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(R.string.token_details_hide_alert_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.token_details_hide_alert_hide),
warning = true,
onClick = onConfirm,
)
},
secondActionBuilder = { cancelAction() },
)
messageSender.send(message)
}
}

View file

@ -0,0 +1,108 @@
package com.tangem.features.managetokens.utils.list
import arrow.core.Either
import arrow.core.left
import com.tangem.domain.managetokens.*
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.managetokens.model.ManageTokensListConfig
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase
import com.tangem.features.managetokens.component.ManageTokensMode
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("LongParameterList")
internal class ManageTokensUseCasesFacade @AssistedInject constructor(
val getManagedTokensUseCase: GetManagedTokensUseCase,
val getDistinctManagedTokensUseCase: GetDistinctManagedCurrenciesUseCase,
private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase,
private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase,
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase,
private val saveManagedTokensUseCase: SaveManagedTokensUseCase,
@Assisted private val mode: ManageTokensMode,
) {
private val nonePortfolioError: IllegalStateException
get() = IllegalStateException("Unsupported")
fun manageTokensListConfig(searchText: String?): ManageTokensListConfig {
val userWalletId: UserWalletId? = when (mode) {
is ManageTokensMode.Account -> TODO("Account")
ManageTokensMode.None -> null
is ManageTokensMode.Wallet -> mode.userWalletId
}
return ManageTokensListConfig(userWalletId, searchText)
}
suspend fun removeCustomCurrencyUseCase(customCurrency: ManagedCryptoCurrency.Custom): Either<Throwable, Unit> {
return when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Wallet -> removeCustomCurrencyUseCase.invoke(
userWalletId = mode.userWalletId,
customCurrency = customCurrency,
)
ManageTokensMode.None -> nonePortfolioError.left()
}
}
suspend fun checkHasLinkedTokensUseCase(
network: Network,
tempAddedTokens: Map<ManagedCryptoCurrency.Token, Set<Network>>,
tempRemovedTokens: Map<ManagedCryptoCurrency.Token, Set<Network>>,
): Either<Throwable, Boolean> {
return when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Wallet -> checkHasLinkedTokensUseCase.invoke(
userWalletId = mode.userWalletId,
network = network,
tempAddedTokens = tempAddedTokens,
tempRemovedTokens = tempRemovedTokens,
)
ManageTokensMode.None -> nonePortfolioError.left()
}
}
suspend fun checkCurrencyUnsupportedUseCase(
sourceNetwork: ManagedCryptoCurrency.SourceNetwork,
): Either<Throwable, CurrencyUnsupportedState?> {
return when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Wallet -> checkCurrencyUnsupportedUseCase.invoke(
userWalletId = mode.userWalletId,
sourceNetwork = sourceNetwork,
)
ManageTokensMode.None -> nonePortfolioError.left()
}
}
suspend fun hasMissedDerivationsUseCase(network: Map<String, Nothing?>): Boolean = when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Wallet -> hasMissedDerivationsUseCase.invoke(
userWalletId = mode.userWalletId,
networksWithDerivationPath = network,
)
ManageTokensMode.None -> false
}
suspend fun saveManagedTokensUseCase(
currenciesToAdd: Map<ManagedCryptoCurrency.Token, Set<Network>>,
currenciesToRemove: Map<ManagedCryptoCurrency.Token, Set<Network>>,
): Either<Throwable, Unit> = when (mode) {
is ManageTokensMode.Account -> TODO("Account")
is ManageTokensMode.Wallet -> saveManagedTokensUseCase.invoke(
userWalletId = mode.userWalletId,
currenciesToAdd = currenciesToAdd,
currenciesToRemove = currenciesToRemove,
)
ManageTokensMode.None -> nonePortfolioError.left()
}
@AssistedFactory
interface Factory {
fun create(mode: ManageTokensMode): ManageTokensUseCasesFacade
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.features.managetokens.utils.list
import com.tangem.core.decompose.ui.UiMessageSender
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.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.impl.R
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class ManageTokensWarningDelegate @AssistedInject constructor(
private val messageSender: UiMessageSender,
@Assisted private val mode: ManageTokensMode,
@Assisted private val source: ManageTokensSource,
@Assisted private val uiActions: ManageTokensUiActions,
) {
suspend fun showRemoveNetworkWarning(
currency: ManagedCryptoCurrency,
network: Network,
isCoin: Boolean,
onConfirm: () -> Unit,
) {
val isNonePortfolio = when (mode) {
ManageTokensMode.None -> true
is ManageTokensMode.Account,
is ManageTokensMode.Wallet,
-> false
}
val hasLinkedTokens = if (isNonePortfolio || !isCoin) {
false
} else {
uiActions.checkHasLinkedTokens(network)
}
val canHideWithoutConfirming = source == ManageTokensSource.ONBOARDING
if (hasLinkedTokens) {
showLinkedTokensWarning(currency, network)
} else if (canHideWithoutConfirming) {
onConfirm()
} else {
showHideTokenWarning(currency, onConfirm)
}
}
private fun showLinkedTokensWarning(currency: ManagedCryptoCurrency, network: Network) {
val message = DialogMessage(
title = resourceReference(
id = R.string.token_details_unable_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(
id = R.string.token_details_unable_hide_alert_message,
formatArgs = wrappedList(
currency.name,
currency.symbol,
network.name,
),
),
)
messageSender.send(message)
}
private fun showHideTokenWarning(currency: ManagedCryptoCurrency, onConfirm: () -> Unit) {
val message = DialogMessage(
title = resourceReference(
id = R.string.token_details_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(R.string.token_details_hide_alert_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.token_details_hide_alert_hide),
warning = true,
onClick = onConfirm,
)
},
secondActionBuilder = { cancelAction() },
)
messageSender.send(message)
}
@AssistedFactory
interface Factory {
fun create(
mode: ManageTokensMode,
source: ManageTokensSource,
uiActions: ManageTokensUiActions,
): ManageTokensWarningDelegate
}
}

View file

@ -17,6 +17,7 @@ dependencies {
api(projects.features.onramp.api)
api(projects.features.sendV2.api)
api(projects.features.tokenRecieve.api)
api(projects.features.wallet.api)
/* Data */
implementation(projects.data.common)

View file

@ -7,7 +7,6 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.ArtworkModel
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -56,7 +55,7 @@ internal class AddToPortfolioBSContentUMFactory(
portfolioUIData: PortfolioUIData,
selectedWallet: UserWallet?,
alreadyAddedNetworks: Set<String>?,
artworks: HashMap<UserWalletId, ArtworkModel>,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
return (currentState ?: TangemBottomSheetConfig.Empty).copy(
isShown = portfolioUIData.portfolioBSVisibilityModel.addToPortfolioBSVisibility,
@ -110,7 +109,7 @@ internal class AddToPortfolioBSContentUMFactory(
}
private fun UserWallet.toSelectedUserWalletItemUM(
artwork: ArtworkModel? = null,
artwork: UserWalletItemUM.ImageState? = null,
portfolioData: PortfolioData,
balance: TotalFiatBalance?,
): UserWalletItemUM {
@ -128,7 +127,7 @@ internal class AddToPortfolioBSContentUMFactory(
isShow: Boolean,
portfolioData: PortfolioData,
selectedWalletId: UserWalletId,
artworks: HashMap<UserWalletId, ArtworkModel>,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
return TangemBottomSheetConfig(
isShown = isShow,

View file

@ -2,6 +2,7 @@ package com.tangem.features.markets.portfolio.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -21,7 +22,6 @@ import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.markets.SaveMarketTokensUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.ArtworkModel
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.currency.CryptoCurrency
@ -32,7 +32,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase
import com.tangem.domain.transaction.usecase.GetEnsNameUseCase
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.HasMissedDerivationsUseCase
import com.tangem.features.markets.impl.R
@ -43,13 +42,14 @@ import com.tangem.features.markets.portfolio.impl.loader.PortfolioDataLoader
import com.tangem.features.markets.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle
import com.tangem.features.wallet.utils.UserWalletImageFetcher
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.operations.attestation.ArtworkSize
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
@ -67,21 +67,17 @@ internal class MarketsPortfolioModel @Inject constructor(
private val portfolioDataLoader: PortfolioDataLoader,
private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase,
private val saveMarketTokensUseCase: SaveMarketTokensUseCase,
private val getCardImageUseCase: GetCardImageUseCase,
private val addToPortfolioManager: AddToPortfolioManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle,
private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase,
private val getEnsNameUseCase: GetEnsNameUseCase,
private val userWalletImageFetcher: UserWalletImageFetcher,
) : Model() {
val state: StateFlow<MyPortfolioUM> get() = _state
private val _state: MutableStateFlow<MyPortfolioUM> = MutableStateFlow(value = MyPortfolioUM.Loading)
private val loadedArtworks: HashMap<UserWalletId, ArtworkModel> = hashMapOf()
private val artworksState: MutableStateFlow<HashMap<UserWalletId, ArtworkModel>> = MutableStateFlow(hashMapOf())
private val loadArtworksMutex = Mutex()
private val params = paramsContainer.require<MarketsPortfolioComponent.Params>()
private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder(
token = params.token,
@ -197,38 +193,33 @@ internal class MarketsPortfolioModel @Inject constructor(
private fun subscribeOnStateUpdates() {
combine(
flow = loadPortfolioData(params.token.id),
flow = loadPortfolioDataWithArtworks(params.token.id),
flow2 = getPortfolioUIDataFlow(),
flow3 = artworksState,
transform = factory::create,
transform = { pair, portfolioUIData ->
val (portfolioData, artworks) = pair
factory.create(portfolioData, portfolioUIData, artworks)
},
)
.onEach { _state.value = it }
.launchIn(modelScope)
}
private fun loadPortfolioData(currencyRawId: CryptoCurrency.RawID): Flow<PortfolioData> {
portfolioDataLoader.load(currencyRawId).onEach {
loadArtworks(it.walletsWithCurrencies.keys.toList())
}.also { return it }
}
private fun loadPortfolioDataWithArtworks(
currencyRawId: CryptoCurrency.RawID,
): Flow<Pair<PortfolioData, Map<UserWalletId, UserWalletItemUM.ImageState>>> {
val wallets = Channel<Set<UserWallet>>()
val portfolioFlow = portfolioDataLoader
.load(currencyRawId)
.onEach { wallets.trySend(it.walletsWithCurrencies.keys) }
private fun loadArtworks(wallets: List<UserWallet>) {
modelScope.launch {
loadArtworksMutex.withLock {
wallets.filterIsInstance<UserWallet.Cold>().forEach { wallet ->
if (!loadedArtworks.containsKey(wallet.walletId)) {
val artwork = getCardImageUseCase(
cardId = wallet.cardId,
manufacturerName = wallet.scanResponse.card.manufacturer.name,
firmwareVersion = wallet.scanResponse.card.firmwareVersion.toSdkFirmwareVersion(),
cardPublicKey = wallet.scanResponse.card.cardPublicKey,
)
loadedArtworks[wallet.walletId] = artwork
artworksState.emit(loadedArtworks)
}
}
}
}
val artworksFlow = wallets.receiveAsFlow()
.distinctUntilChanged()
.flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) }
return combine(
flow = portfolioFlow,
flow2 = artworksFlow,
) { portfolioData, artworks -> portfolioData to artworks }
}
private fun getPortfolioUIDataFlow(): Flow<PortfolioUIData> {

View file

@ -1,8 +1,8 @@
package com.tangem.features.markets.portfolio.impl.model
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.ArtworkModel
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -36,7 +36,7 @@ internal class MyPortfolioUMFactory(
fun create(
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
artworks: HashMap<UserWalletId, ArtworkModel>,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): MyPortfolioUM {
val addToPortfolioData = portfolioUIData.addToPortfolioData
@ -89,7 +89,7 @@ internal class MyPortfolioUMFactory(
private fun createAddToPortfolioBSConfig(
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
artworks: HashMap<UserWalletId, ArtworkModel>,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
val selectedWallet = portfolioData.walletsWithCurrencies.keys
.firstOrNull { it.walletId == portfolioUIData.selectedWalletId }

View file

@ -13,12 +13,14 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.onboarding.v2.TitleProvider
import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
@ -46,6 +48,8 @@ internal class OnboardingEntryModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val uiMessageSender: UiMessageSender,
private val userWalletsListManager: UserWalletsListManager,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val userWalletsListRepository: UserWalletsListRepository,
) : Model() {
private val params = paramsContainer.require<OnboardingEntryComponent.Params>()
@ -197,6 +201,19 @@ internal class OnboardingEntryModel @Inject constructor(
}
private fun exitComponentScreen() {
// new flow
if (hotWalletFeatureToggles.isHotWalletEnabled) {
modelScope.launch {
if (userWalletsListRepository.userWalletsSync().isEmpty()) {
router.replaceAll(AppRoute.Home())
} else {
router.replaceAll(AppRoute.Wallet)
}
}
return
}
// legacy flow
if (userWalletsListManager.hasUserWallets) {
val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync!! }.getOrElse { false }

View file

@ -9,7 +9,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
@ -39,7 +39,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val tangemSdkManager: TangemSdkManager,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val cardRepository: CardRepository,
private val analyticsHandler: AnalyticsEventHandler,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
@ -168,7 +168,8 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
fun navigateToSupportScreen() {
modelScope.launch {
val cardInfo = getCardInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch
val cardInfo =
getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch
sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo))
}
}

View file

@ -9,7 +9,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.CardDTO
@ -49,7 +49,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val backupServiceHolder: BackupServiceHolder,
private val tangemSdkManager: TangemSdkManager,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
@ -79,7 +79,8 @@ internal class MultiWalletFinalizeModel @Inject constructor(
// sets proper artwork state for initial step
// (if we start from backup cards, we need to show proper artwork) ([REDACTED_TASK_KEY])
when (getInitialStep()) {
MultiWalletFinalizeUM.Step.Primary -> { /* state is already set */ }
MultiWalletFinalizeUM.Step.Primary -> { /* state is already set */
}
MultiWalletFinalizeUM.Step.BackupDevice1 -> {
onEvent.emit(MultiWalletFinalizeComponent.Event.OneBackupCardAdded)
}
@ -333,7 +334,8 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private fun navigateToSupportScreen() {
modelScope.launch {
val cardInfo = getCardInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch
val cardInfo =
getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch
sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo))
}
}

View file

@ -10,7 +10,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.crypto.bip39.Mnemonic
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.features.hotwallet.MnemonicRepository
@ -44,7 +44,7 @@ internal class MultiWalletSeedPhraseModel @Inject constructor(
private val urlOpener: UrlOpener,
private val tangemSdkManager: TangemSdkManager,
private val cardRepository: CardRepository,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val analyticsHandler: AnalyticsEventHandler,
) : Model() {
@ -257,7 +257,8 @@ internal class MultiWalletSeedPhraseModel @Inject constructor(
fun navigateToSupportScreen() {
modelScope.launch {
val cardInfo = getCardInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch
val cardInfo =
getWalletMetaInfoUseCase(multiWalletState.value.currentScanResponse).getOrNull() ?: return@launch
sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo))
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder
import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem
import com.tangem.crypto.bip39.Mnemonic
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM
@ -20,10 +21,10 @@ internal class GenerateSeedPhraseUiStateBuilder(
option: GeneratedWordsType,
): MultiWalletSeedPhraseUM.GenerateSeedPhrase {
val words12 = generatedWords12.mnemonicComponents.mapIndexed { index, s ->
MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s)
EnumeratedTwoColumnGridItem(index + 1, s)
}.toImmutableList()
val words24 = generatedWords24.mnemonicComponents.mapIndexed { index, s ->
MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s)
EnumeratedTwoColumnGridItem(index + 1, s)
}.toImmutableList()
return MultiWalletSeedPhraseUM.GenerateSeedPhrase(
@ -45,8 +46,8 @@ internal class GenerateSeedPhraseUiStateBuilder(
private fun switchType(
newType: GeneratedWordsType,
generatedWords12: ImmutableList<MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem>,
generatedWords24: ImmutableList<MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem>,
generatedWords12: ImmutableList<EnumeratedTwoColumnGridItem>,
generatedWords24: ImmutableList<EnumeratedTwoColumnGridItem>,
) {
updateUiState { uiSt ->
changeGeneratedWordsType(newType)

View file

@ -5,15 +5,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.grid.EnumeratedTwoColumnGrid
import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem
import com.tangem.core.ui.extensions.pluralStringResourceSafe
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -21,8 +20,6 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -45,8 +42,8 @@ internal fun MultiWalletSeedPhraseWords(
TitleBlock(state)
SeedPhraseGridBlock(
mnemonicGridItems = state.words,
EnumeratedTwoColumnGrid(
items = state.words,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp, bottom = 32.dp),
@ -127,67 +124,6 @@ private fun TitleBlock(state: MultiWalletSeedPhraseUM.GenerateSeedPhrase, modifi
}
}
@Composable
private fun SeedPhraseGridBlock(mnemonicGridItems: ImmutableList<MnemonicGridItem>, modifier: Modifier = Modifier) {
VerticalGrid(
modifier = modifier,
items = mnemonicGridItems,
) { item ->
Row(
modifier = Modifier.padding(all = TangemTheme.dimens.size8),
verticalAlignment = Alignment.CenterVertically,
) {
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
} else {
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
}
@Composable
private inline fun <T> VerticalGrid(
items: ImmutableList<T>,
modifier: Modifier = Modifier,
crossinline content: @Composable (T) -> Unit,
) {
val columnLength = items.size / 2
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
repeat(2) { index ->
Column {
for (i in 0 until columnLength) {
val item = items[index * columnLength + i]
content(item)
}
}
}
}
}
@Preview(showBackground = true, heightDp = 640)
@Composable
private fun Preview() {
@ -195,7 +131,7 @@ private fun Preview() {
MultiWalletSeedPhraseWords(
state = MultiWalletSeedPhraseUM.GenerateSeedPhrase(
words = List(24) {
MnemonicGridItem(
EnumeratedTwoColumnGridItem(
index = it + 1,
mnemonic = "word1",
)

View file

@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.s
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType
@ -22,15 +23,10 @@ internal sealed class MultiWalletSeedPhraseUM(
data class GenerateSeedPhrase(
val option: GeneratedWordsType = GeneratedWordsType.Words12,
val words: ImmutableList<MnemonicGridItem> = persistentListOf(),
val words: ImmutableList<EnumeratedTwoColumnGridItem> = persistentListOf(),
val onOptionChange: (GeneratedWordsType) -> Unit = {},
val onContinueClick: () -> Unit = {},
) : MultiWalletSeedPhraseUM(order = 1) {
data class MnemonicGridItem(
val index: Int,
val mnemonic: String,
)
}
) : MultiWalletSeedPhraseUM(order = 1)
data class GeneratedWordsCheck(
val wordFields: ImmutableList<WordField> = persistentListOf(),

View file

@ -10,7 +10,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.features.onboarding.v2.stepper.api.OnboardingStepperComponent
@ -24,7 +24,7 @@ import kotlinx.coroutines.launch
internal class DefaultOnboardingStepperComponent @AssistedInject constructor(
@Assisted val context: AppComponentContext,
@Assisted val params: OnboardingStepperComponent.Params,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val analyticsHandler: AnalyticsEventHandler,
) : OnboardingStepperComponent, AppComponentContext by context {
@ -40,7 +40,7 @@ internal class DefaultOnboardingStepperComponent @AssistedInject constructor(
)
componentScope.launch {
val cardInfo = getCardInfoUseCase(params.scanResponse).getOrNull() ?: return@launch
val cardInfo = getWalletMetaInfoUseCase(params.scanResponse).getOrNull() ?: return@launch
sendFeedbackEmailUseCase(
if (params.scanResponse.card.isVisa) {
FeedbackEmailType.Visa.Activation(cardInfo)

View file

@ -18,7 +18,7 @@ import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent
import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.ui.state.OnboardingVisaAccessCodeUM
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent
@ -44,7 +44,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
@Suppress("UnusedPrivateMember")
private val tangemSdkManager: TangemSdkManager,
private val visaAuthRepository: VisaAuthRepository,
private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource,
private val uiMessageSender: UiMessageSender,
private val analyticsEventsHandler: AnalyticsEventHandler,
) : Model() {
@ -155,7 +155,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
loading(true)
modelScope.launch {
val challengeToSign = visaAuthRepository.getCardAuthChallenge(
val challengeToSign = visaAuthRemoteDataSource.getCardAuthChallenge(
cardId = activationInput.cardId,
cardPublicKey = activationInput.cardPublicKey,
).getOrElse {

View file

@ -18,7 +18,7 @@ import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
@ -42,7 +42,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
paramsContainer: ParamsContainer,
visaActivationRepositoryFactory: VisaActivationRepository.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val visaAuthRepository: VisaAuthRepository,
private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val otpStorage: VisaOTPStorage,
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
@ -166,7 +166,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
val authTokens = visaAuthTokenStorage.get(params.scanResponse.card.cardId)
?: error("Auth tokens are not found. This should not happen.")
val newTokens = visaAuthRepository.exchangeAccessToken(authTokens)
val newTokens = visaAuthRemoteDataSource.exchangeAccessToken(authTokens)
.getOrElse {
uiMessageSender.showErrorDialog(it)
return

View file

@ -8,7 +8,12 @@ import com.tangem.domain.models.wallet.UserWalletId
interface OnrampComponent : ComposableContentComponent {
data class Params(val userWalletId: UserWalletId, val cryptoCurrency: CryptoCurrency, val source: OnrampSource)
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val source: OnrampSource,
val launchSepa: Boolean = false,
)
interface Factory : ComponentFactory<Params, OnrampComponent>
}

View file

@ -15,6 +15,7 @@ internal interface OnrampMainComponent : ComposableContentComponent {
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val launchSepa: Boolean,
)
interface Factory : ComponentFactory<Params, OnrampMainComponent>

View file

@ -10,4 +10,5 @@ interface OnrampIntents {
fun openProviders()
fun onRefresh()
fun onLinkClick(link: String)
fun onContinueClick()
}

View file

@ -148,4 +148,8 @@ internal class OnrampStateFactory(
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
)
companion object {
const val PREDEFINED_SEPA_AMOUNT = "100"
}
}

View file

@ -28,6 +28,7 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory.Companion.PREDEFINED_SEPA_AMOUNT
import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory
import com.tangem.features.onramp.providers.entity.SelectProviderResult
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
@ -64,6 +65,8 @@ internal class OnrampMainComponentModel @Inject constructor(
private val params: OnrampMainComponent.Params = paramsContainer.require()
private var isSepaLaunched = false
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
private val stateFactory = OnrampStateFactory(
@ -148,11 +151,16 @@ internal class OnrampMainComponentModel @Inject constructor(
if (country == null) return@onEach
_state.update {
if (it is OnrampMainComponentUM.InitialLoading) {
stateFactory.getReadyState(country.defaultCurrency)
val state = stateFactory.getReadyState(country.defaultCurrency)
if (params.launchSepa) {
onAmountValueChanged(PREDEFINED_SEPA_AMOUNT)
}
state
} else {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
}
updatePairsAndQuotes()
},
)
@ -281,6 +289,8 @@ internal class OnrampMainComponentModel @Inject constructor(
override fun onLinkClick(link: String) = urlOpener.openUrl(link)
override fun onContinueClick() = Unit
override fun onDestroy() {
modelScope.launch { clearOnrampCacheUseCase.invoke() }
quotesTaskScheduler.cancelTask()
@ -315,8 +325,18 @@ internal class OnrampMainComponentModel @Inject constructor(
private fun selectOrUpdateQuote(quotes: List<OnrampQuote>): OnrampQuote? {
val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error }
val sepaQuote = if (params.launchSepa && !isSepaLaunched) {
isSepaLaunched = true
quotes.filterIsInstance<OnrampQuote.Data>().firstOrNull {
it.provider.id == MERCURYO_PROVIDER_ID && it.paymentMethod.id == SEPA_METHOD_ID
}
} else {
null
}
// Check if amount, country or currency has changed
val newQuote = if (checkLastInputState(quoteToCheck)) {
val newQuote = sepaQuote ?: if (checkLastInputState(quoteToCheck)) {
quoteToCheck
} else {
val state = state.value as? OnrampMainComponentUM.Content
@ -418,5 +438,8 @@ internal class OnrampMainComponentModel @Inject constructor(
private companion object {
const val UPDATE_DELAY = 10_000L
const val MERCURYO_PROVIDER_ID = "mercuryo"
const val SEPA_METHOD_ID = "sepa"
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.onramp.mainv2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainBottomSheetConfig
import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel
import com.tangem.features.onramp.mainv2.ui.OnrampNewMainScreen
import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnrampV2MainComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: OnrampV2MainComponent.Params,
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
) : OnrampV2MainComponent, AppComponentContext by appComponentContext {
private val model: OnrampV2MainComponentModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsState()
val bottomSheet by bottomSheetSlot.subscribeAsState()
OnrampNewMainScreen(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: OnrampV2MainBottomSheetConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is OnrampV2MainBottomSheetConfig.ConfirmResidency -> confirmResidencyComponentFactory.create(
context = childByContext(componentContext),
params = ConfirmResidencyComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
country = config.country,
onDismiss = { model.bottomSheetNavigation.dismiss() },
),
)
is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create(
context = childByContext(componentContext),
params = SelectCurrencyComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory
interface Factory : OnrampV2MainComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnrampV2MainComponent.Params,
): DefaultOnrampV2MainComponent
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.onramp.mainv2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
class DefaultOnrampV2MainFeatureToggle(
private val featureTogglesManager: FeatureTogglesManager,
) : OnrampV2MainFeatureToggle {
override val isOnrampNewMainEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("NEW_ONRAMP_RECEIVE_ENABLED")
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.onramp.mainv2
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampSource
internal interface OnrampV2MainComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
)
interface Factory : ComponentFactory<Params, OnrampV2MainComponent>
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.onramp.mainv2
internal interface OnrampV2MainFeatureToggle {
val isOnrampNewMainEnabled: Boolean
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.onramp.mainv2.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.mainv2.model.OnrampV2MainComponentModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface OnrampMainV2ComponentModelModule {
@Binds
@IntoMap
@ClassKey(OnrampV2MainComponentModel::class)
fun bindOnrampSelectCountryModel(model: OnrampV2MainComponentModel): Model
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.onramp.mainv2.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainComponent
import com.tangem.features.onramp.mainv2.DefaultOnrampV2MainFeatureToggle
import com.tangem.features.onramp.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.OnrampV2MainFeatureToggle
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface OnrampNewMainComponentModule {
@Binds
@Singleton
fun bindOnrampV2MainComponentFactory(factory: DefaultOnrampV2MainComponent.Factory): OnrampV2MainComponent.Factory
}
@Module
@InstallIn(SingletonComponent::class)
internal object FeatureToggleModule {
@Provides
@Singleton
fun provideOnrampV2MainFeatureToggle(featureTogglesManager: FeatureTogglesManager): OnrampV2MainFeatureToggle {
return DefaultOnrampV2MainFeatureToggle(featureTogglesManager = featureTogglesManager)
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.onramp.mainv2.entity
import androidx.compose.runtime.Immutable
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed interface OnrampOffersBlockUM {
val isBlockVisible: Boolean
data object Empty : OnrampOffersBlockUM {
override val isBlockVisible: Boolean
get() = false
}
data class Loading(
override val isBlockVisible: Boolean,
) : OnrampOffersBlockUM
data class Content(
override val isBlockVisible: Boolean,
val offers: ImmutableList<OnrampOfferUM>,
) : OnrampOffersBlockUM
}
internal data class OnrampOfferUM(
val category: OnrampOfferCategory,
val advantages: OnrampOfferAdvantages,
val paymentMethod: OnrampPaymentMethod,
val providerId: String,
val providerName: String,
)
internal enum class OnrampOfferCategory {
RecentlyUsed, Recommended
}
internal enum class OnrampOfferAdvantages {
Default, BestRate, Fastest
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.onramp.mainv2.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class OnrampNewAmountBlockUM(
val currencyUM: OnrampNewCurrencyUM,
val amountFieldModel: AmountFieldModel,
val secondaryFieldModel: OnrampNewAmountSecondaryFieldUM,
)
internal data class OnrampNewCurrencyUM(
val unit: String,
val code: String,
val iconUrl: String?,
val precision: Int,
val onClick: () -> Unit,
)
@Immutable
internal sealed interface OnrampNewAmountSecondaryFieldUM {
data object Loading : OnrampNewAmountSecondaryFieldUM
data class Content(val amount: TextReference) : OnrampNewAmountSecondaryFieldUM
data class Error(val error: TextReference) : OnrampNewAmountSecondaryFieldUM
}
internal sealed interface OnrampV2AmountButtonUMState {
data class Loaded(val amountButtons: ImmutableList<OnrampAmountButtonUM>) : OnrampV2AmountButtonUMState
data object None : OnrampV2AmountButtonUMState
}
internal data class OnrampAmountButtonUM(
val value: Int,
val currency: String,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,13 @@
package com.tangem.features.onramp.mainv2.entity
import com.tangem.domain.onramp.model.OnrampCountry
import kotlinx.serialization.Serializable
@Serializable
sealed interface OnrampV2MainBottomSheetConfig {
@Serializable
data class ConfirmResidency(val country: OnrampCountry) : OnrampV2MainBottomSheetConfig
@Serializable
data object CurrenciesList : OnrampV2MainBottomSheetConfig
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.onramp.mainv2.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed interface OnrampV2MainComponentUM {
val topBarConfig: OnrampV2MainTopBarUM
val continueButtonConfig: ContinueButtonUM
val errorNotification: NotificationUM?
data class InitialLoading(
override val topBarConfig: OnrampV2MainTopBarUM,
override val continueButtonConfig: ContinueButtonUM,
override val errorNotification: NotificationUM?,
) : OnrampV2MainComponentUM
data class Content(
override val topBarConfig: OnrampV2MainTopBarUM,
override val continueButtonConfig: ContinueButtonUM,
override val errorNotification: NotificationUM?,
val amountBlockState: OnrampNewAmountBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampV2AmountButtonUMState,
val onrampProviderState: OnrampV2ProvidersUM,
) : OnrampV2MainComponentUM
}
internal data class ContinueButtonUM(
val text: TextReference,
val onClick: () -> Unit,
val enabled: Boolean,
val showProgress: Boolean = false,
)
internal data class OnrampV2MainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,
)

View file

@ -0,0 +1,15 @@
package com.tangem.features.onramp.mainv2.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
sealed interface OnrampV2ProvidersUM {
data object Empty : OnrampV2ProvidersUM
data object Loading : OnrampV2ProvidersUM
data class Content(
val providerId: String,
val paymentMethod: OnrampPaymentMethod,
) : OnrampV2ProvidersUM
}

View file

@ -0,0 +1,84 @@
package com.tangem.features.onramp.mainv2.entity.converter
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.onramp.main.entity.OnrampIntents
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class OnrampV2AmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val onrampIntents: OnrampIntents,
private val cryptoCurrency: CryptoCurrency,
) : Converter<String, OnrampV2MainComponentUM> {
override fun convert(value: String): OnrampV2MainComponentUM {
val state = currentStateProvider()
if (state !is OnrampV2MainComponentUM.Content) return state
if (value.isEmpty()) return state.emptyState()
val amountState = state.amountBlockState
val amountTextField = amountState.amountFieldModel
val fiatDecimal = value.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val amountFieldModel = amountState.amountFieldModel.copy(
fiatValue = value,
fiatAmount = amountTextField.fiatAmount.copy(value = fiatDecimal),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
return state.copy(
amountBlockState = amountState.copy(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading,
),
continueButtonConfig = state.continueButtonConfig.copy(enabled = false),
onrampProviderState = OnrampV2ProvidersUM.Loading,
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
)
}
private fun OnrampV2MainComponentUM.Content.emptyState(): OnrampV2MainComponentUM.Content {
val amountFieldModel = amountBlockState.amountFieldModel.copy(
value = "",
fiatValue = "",
cryptoAmount = amountBlockState.amountFieldModel.cryptoAmount.copy(value = BigDecimal.ZERO),
fiatAmount = amountBlockState.amountFieldModel.fiatAmount.copy(value = BigDecimal.ZERO),
isError = false,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,
),
)
return copy(
amountBlockState = amountBlockState.copy(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
),
continueButtonConfig = continueButtonConfig.copy(enabled = false),
offersBlockState = OnrampOffersBlockUM.Empty,
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencySymbol = amountBlockState.currencyUM.unit,
currencyCode = amountBlockState.currencyUM.code,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
onrampProviderState = OnrampV2ProvidersUM.Empty,
)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.onramp.mainv2.entity.factory
import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState
import kotlinx.collections.immutable.toPersistentList
internal class OnrampAmountButtonUMStateFactory {
private val defaultPreselectedAmount = listOf(50, 100, 200, 300, 500)
fun createOnrampAmountActionButton(
currencyCode: String,
currencySymbol: String,
onAmountValueChanged: (String) -> Unit,
): OnrampV2AmountButtonUMState {
return when (currencyCode) {
USD_CODE, EUR_CODE -> {
val buttons = defaultPreselectedAmount.map { value ->
OnrampAmountButtonUM(
value = value,
currency = currencySymbol,
onClick = { onAmountValueChanged(value.toString()) },
)
}.toPersistentList()
OnrampV2AmountButtonUMState.Loaded(buttons)
}
else -> OnrampV2AmountButtonUMState.None
}
}
companion object {
private const val USD_CODE = "USD"
private const val EUR_CODE = "EUR"
}
}

View file

@ -0,0 +1,222 @@
package com.tangem.features.onramp.mainv2.entity.factory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.AmountType
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampIntents
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.converter.OnrampV2AmountFieldChangeConverter
import com.tangem.utils.Provider
import java.math.BigDecimal
internal class OnrampV2AmountStateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onrampIntents: OnrampIntents,
private val cryptoCurrency: CryptoCurrency,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
) {
private val onrampAmountFieldChangeConverter: OnrampV2AmountFieldChangeConverter by lazy(
mode = LazyThreadSafetyMode.NONE,
) {
OnrampV2AmountFieldChangeConverter(
currentStateProvider = currentStateProvider,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
onrampIntents = onrampIntents,
cryptoCurrency = cryptoCurrency,
)
}
fun getOnAmountValueChange(value: String): OnrampV2MainComponentUM {
return onrampAmountFieldChangeConverter.convert(value)
}
fun getUpdatedCurrencyState(currency: OnrampCurrency): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
return currentState.copy(
amountBlockState = amountState.copy(
currencyUM = amountState.currencyUM.copy(
unit = currency.unit,
code = currency.code,
iconUrl = currency.image,
precision = currency.precision,
),
amountFieldModel = amountState.amountFieldModel.copy(
isError = false,
fiatAmount = amountState.amountFieldModel.fiatAmount.copy(
currencySymbol = currency.unit,
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
),
),
),
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencyCode = currency.code,
currencySymbol = currency.unit,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
)
}
fun getAmountSecondaryLoadingState(): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading,
),
offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false),
continueButtonConfig = currentState.continueButtonConfig.copy(enabled = false),
errorNotification = null,
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampProviderState = OnrampV2ProvidersUM.Loading,
)
}
fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.amountFieldModel.fiatValue.isEmpty()) return currentState
return currentState.copy(
amountBlockState = amountState.copy(
amountFieldModel = amountState.amountFieldModel.copy(isError = false),
secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel,
),
continueButtonConfig = currentState.continueButtonConfig.copy(
enabled = quote is OnrampQuote.Data,
onClick = onrampIntents::onContinueClick,
),
errorNotification = null,
)
}
fun getUpdatedProviderState(selectedQuote: OnrampQuote): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
analyticsEventHandler.send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = selectedQuote.provider.info.name,
tokenSymbol = cryptoCurrency.symbol,
paymentMethod = selectedQuote.paymentMethod.name,
),
)
return currentState.copy(
onrampProviderState = selectedQuote.toProviderBlockState(),
)
}
fun getAmountSecondaryResetState(): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
val amountState = currentState.amountBlockState
if (amountState.secondaryFieldModel is OnrampNewAmountSecondaryFieldUM.Content) return currentState
return currentState.copy(
amountBlockState = amountState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
amount = stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
errorNotification = null,
)
}
fun getShowProvidersState(): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
return when (currentState.offersBlockState) {
is OnrampOffersBlockUM.Content -> {
currentState.copy(
offersBlockState = currentState.offersBlockState.copy(isBlockVisible = true),
)
}
OnrampOffersBlockUM.Empty,
is OnrampOffersBlockUM.Loading,
-> currentState
}
}
private fun OnrampQuote.toProviderBlockState(): OnrampV2ProvidersUM {
return OnrampV2ProvidersUM.Content(
paymentMethod = paymentMethod,
providerId = provider.id,
)
}
private fun OnrampQuote.toSecondaryFieldUiModel(
amountState: OnrampNewAmountBlockUM,
): OnrampNewAmountSecondaryFieldUM? {
return when (this) {
is OnrampQuote.Error -> null
is OnrampQuote.Data -> {
val amount = toAmount.value.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
}
val contentAmount = combinedReference(stringReference("\u007E"), stringReference(amount))
OnrampNewAmountSecondaryFieldUM.Content(contentAmount)
}
is OnrampQuote.AmountError -> this.toSecondaryFieldUiModel(amountState)
}
}
private fun OnrampQuote.AmountError.toSecondaryFieldUiModel(
amountState: OnrampNewAmountBlockUM,
): OnrampNewAmountSecondaryFieldUM.Error {
val amount = error.requiredAmount.format {
fiat(
fiatCurrencyCode = amountState.amountFieldModel.fiatAmount.currencySymbol,
fiatCurrencySymbol = amountState.amountFieldModel.fiatAmount.currencySymbol,
)
}
val errorTextRes = when (error) {
is OnrampError.AmountError.TooBigError -> {
analyticsEventHandler.send(OnrampAnalyticsEvent.MaxAmountError)
R.string.onramp_max_amount_restriction
}
is OnrampError.AmountError.TooSmallError -> {
analyticsEventHandler.send(OnrampAnalyticsEvent.MinAmountError)
R.string.onramp_min_amount_restriction
}
}
return OnrampNewAmountSecondaryFieldUM.Error(
resourceReference(
errorTextRes,
wrappedList(amount),
),
)
}
}

View file

@ -0,0 +1,205 @@
package com.tangem.features.onramp.mainv2.entity.factory
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.tokens.model.convertToAmount
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampIntents
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.utils.Provider
import java.math.BigDecimal
internal class OnrampV2StateFactory(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val cryptoCurrency: CryptoCurrency,
private val onrampIntents: OnrampIntents,
) {
fun getInitialState(
currency: String,
onClose: () -> Unit,
openSettings: () -> Unit,
): OnrampV2MainComponentUM.InitialLoading {
return OnrampV2MainComponentUM.InitialLoading(
errorNotification = null,
topBarConfig = OnrampV2MainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
startButtonUM = TopAppBarButtonUM.Close(
onCloseClick = onClose,
enabled = true,
),
endButtonUM = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_more_vertical_24,
onClicked = openSettings,
enabled = false,
),
),
continueButtonConfig = ContinueButtonUM(
text = resourceReference(R.string.common_continue),
onClick = {},
enabled = false,
),
)
}
fun getReadyState(currency: OnrampCurrency): OnrampV2MainComponentUM.Content {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
is TopAppBarButtonUM.Icon -> button.copy(enabled = true)
is TopAppBarButtonUM.Text -> button.copy(enabled = true)
}
val initialAmountBlockState = getInitialAmountBlockState(currency)
return OnrampV2MainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
continueButtonConfig = ContinueButtonUM(
text = resourceReference(R.string.common_continue),
onClick = onrampIntents::onContinueClick,
enabled = false,
),
amountBlockState = initialAmountBlockState,
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = null,
onrampAmountButtonUMState = onrampAmountButtonUMStateFactory.createOnrampAmountActionButton(
currencyCode = currency.code,
currencySymbol = currency.unit,
onAmountValueChanged = onrampIntents::onAmountValueChanged,
),
onrampProviderState = OnrampV2ProvidersUM.Empty,
)
}
fun getOnrampErrorState(onrampError: OnrampError): OnrampV2MainComponentUM {
return when (onrampError) {
OnrampError.PairsNotFound -> getNoPairsErrorState()
is OnrampError.DataError -> getErrorState(
errorCode = onrampError.code,
onRefresh = onrampIntents::onRefresh,
)
is OnrampError.DomainError -> getErrorState(onRefresh = onrampIntents::onRefresh)
is OnrampError.AmountError.TooBigError,
is OnrampError.AmountError.TooSmallError,
OnrampError.RedirectError.VerificationFailed,
OnrampError.RedirectError.WrongRequestId,
-> currentStateProvider() // ignore error state
}
}
private fun getNoPairsErrorState(): OnrampV2MainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampV2MainComponentUM.Content ?: return state
return contentState.copy(
continueButtonConfig = contentState.continueButtonConfig.copy(enabled = false),
amountBlockState = contentState.amountBlockState.copy(
amountFieldModel = contentState.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Error(
error = resourceReference(R.string.onramp_no_available_providers),
),
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}
fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampV2MainComponentUM {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
is TopAppBarButtonUM.Icon -> button.copy(enabled = true)
is TopAppBarButtonUM.Text -> button.copy(enabled = true)
}
return when (state) {
is OnrampV2MainComponentUM.Content -> state.copy(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
continueButtonConfig = state.continueButtonConfig.copy(enabled = false),
amountBlockState = state.amountBlockState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
),
offersBlockState = OnrampOffersBlockUM.Empty,
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampProviderState = OnrampV2ProvidersUM.Empty,
)
is OnrampV2MainComponentUM.InitialLoading -> state.copy(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
),
)
}
}
private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampNewAmountBlockUM {
return OnrampNewAmountBlockUM(
currencyUM = OnrampNewCurrencyUM(
code = currency.code,
iconUrl = currency.image,
precision = currency.precision,
onClick = onrampIntents::openCurrenciesList,
unit = currency.unit,
),
amountFieldModel = AmountFieldModel(
value = "",
fiatValue = "",
onValueChange = onrampIntents::onAmountValueChanged,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
isFiatValue = true,
cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency),
fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency),
isError = false,
isWarning = false,
error = TextReference.EMPTY,
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
),
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Content(
stringReference(
BigDecimal.ZERO.format {
crypto(cryptoCurrency = cryptoCurrency, ignoreSymbolPosition = true)
},
),
),
)
}
private fun BigDecimal.convertToFiatAmount(currency: OnrampCurrency): Amount = Amount(
currencySymbol = currency.unit,
value = this,
decimals = currency.precision,
type = AmountType.FiatType(currency.code),
)
}

View file

@ -0,0 +1,359 @@
package com.tangem.features.onramp.mainv2.model
import androidx.compose.runtime.mutableStateOf
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.main.entity.OnrampIntents
import com.tangem.features.onramp.main.entity.OnrampLastUpdate
import com.tangem.features.onramp.mainv2.OnrampV2MainComponent
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2AmountStateFactory
import com.tangem.features.onramp.mainv2.entity.factory.OnrampV2StateFactory
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
internal class OnrampV2MainComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val router: Router,
private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase,
private val getOnrampCountryUseCase: GetOnrampCountryUseCase,
private val clearOnrampCacheUseCase: ClearOnrampCacheUseCase,
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
private val amountInputManager: InputManager,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model(), OnrampIntents {
val params = paramsContainer.require<OnrampV2MainComponent.Params>()
private var loadQuotesJob: Job? = null
private val lastUpdateState = mutableStateOf<OnrampLastUpdate?>(null)
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory by lazy(LazyThreadSafetyMode.NONE) {
OnrampAmountButtonUMStateFactory()
}
private val stateFactory = OnrampV2StateFactory(
currentStateProvider = Provider { _state.value },
cryptoCurrency = params.cryptoCurrency,
onrampIntents = this,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
private val amountStateFactory = OnrampV2AmountStateFactory(
currentStateProvider = Provider { _state.value },
analyticsEventHandler = analyticsEventHandler,
onrampIntents = this,
cryptoCurrency = params.cryptoCurrency,
onrampAmountButtonUMStateFactory = onrampAmountButtonUMStateFactory,
)
private val _state: MutableStateFlow<OnrampV2MainComponentUM> = MutableStateFlow(
value = stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = ::onCloseClick,
openSettings = ::openSettings,
),
)
val state: StateFlow<OnrampV2MainComponentUM> get() = _state.asStateFlow()
val bottomSheetNavigation: SlotNavigation<OnrampV2MainBottomSheetConfig> = SlotNavigation()
val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
init {
modelScope.launch {
clearOnrampCacheUseCase()
}
sendScreenOpenAnalytics()
checkResidenceCountry()
subscribeToAmountChanges()
subscribeToCountryAndCurrencyUpdates()
subscribeToQuotesUpdate()
}
override fun onDestroy() {
modelScope.launch { clearOnrampCacheUseCase.invoke() }
loadQuotesJob?.cancel()
super.onDestroy()
}
override fun onAmountValueChanged(value: String) {
_state.update { amountStateFactory.getOnAmountValueChange(value) }
modelScope.launch { amountInputManager.update(value) }
}
override fun openSettings() {
params.openSettings
}
override fun openCurrenciesList() {
analyticsEventHandler.send(OnrampAnalyticsEvent.SelectCurrencyScreenOpened)
bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.CurrenciesList)
}
override fun onBuyClick(quote: OnrampProviderWithQuote.Data) {
// TODO in [REDACTED_TASK_KEY] to be continued
}
override fun openProviders() {
// TODO in [REDACTED_TASK_KEY] to be continued
}
override fun onRefresh() {
_state.update {
stateFactory.getInitialState(
currency = params.cryptoCurrency.name,
onClose = router::pop,
openSettings = ::openSettings,
)
}
loadQuotesJob?.cancel()
modelScope.launch {
clearOnrampCacheUseCase.invoke()
checkResidenceCountry()
}
}
override fun onLinkClick(link: String) {
// TODO in [REDACTED_TASK_KEY] will be deleted
}
override fun onContinueClick() {
val currentState = _state.value
if (currentState is OnrampV2MainComponentUM.Content) {
_state.update { amountStateFactory.getShowProvidersState() }
}
}
private fun checkResidenceCountry() {
modelScope.launch {
checkOnrampAvailabilityUseCase(userWallet)
.onRight(::handleOnrampAvailability)
.onLeft(::handleOnrampError)
}
}
private fun handleOnrampAvailability(availability: OnrampAvailability) {
when (availability) {
is OnrampAvailability.Available -> Unit
is OnrampAvailability.ConfirmResidency,
is OnrampAvailability.NotSupported,
-> bottomSheetNavigation.activate(OnrampV2MainBottomSheetConfig.ConfirmResidency(availability.country))
}
}
private fun onCloseClick() {
analyticsEventHandler.send(OnrampAnalyticsEvent.CloseOnramp)
router.pop()
}
private fun subscribeToAmountChanges() = modelScope.launch {
amountInputManager.query
.filter(String::isNotEmpty)
.collectLatest { _ ->
_state.update { amountStateFactory.getAmountSecondaryLoadingState() }
loadQuotes()
}
}
private fun subscribeToCountryAndCurrencyUpdates() {
getOnrampCountryUseCase.invoke()
.onEach { maybeCountry ->
maybeCountry.fold(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
_state.update {
when (it) {
is OnrampV2MainComponentUM.Content -> {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampV2MainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency)
}
}
}
updatePairsAndQuotes()
},
)
}
.launchIn(modelScope)
}
private fun subscribeToQuotesUpdate() {
getOnrampQuotesUseCase.invoke()
.conflate()
.onEach { maybeQuotes ->
maybeQuotes.fold(
ifLeft = ::handleOnrampError,
ifRight = ::handleQuoteResult,
)
}
.launchIn(modelScope)
}
private fun handleQuoteResult(quotes: List<OnrampQuote>) {
sendOnrampQuotesErrorAnalytic(quotes)
val quote = selectOrUpdateQuote(quotes)
if (quote == null) {
_state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
lastUpdateState.value = null
return
}
_state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) }
}
private fun selectOrUpdateQuote(quotes: List<OnrampQuote>): OnrampQuote? {
val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error }
// Check if amount, country or currency has changed
val newQuote = if (checkLastInputState(quoteToCheck)) {
quoteToCheck
} else {
val state = state.value as? OnrampV2MainComponentUM.Content
val providerState = state?.onrampProviderState as? OnrampV2ProvidersUM.Content
// Get current selected quote to update
val lastSelectedQuote = quotes.firstOrNull {
it.provider.id == providerState?.providerId &&
it.paymentMethod.id == providerState.paymentMethod.id
}
if (lastSelectedQuote is OnrampQuote.Error) {
quoteToCheck
} else {
lastSelectedQuote
}
}
newQuote?.let { updateProvider(newQuote) }
return newQuote
}
private fun onRetryQuotes() {
_state.update {
(it as? OnrampV2MainComponentUM.Content)?.copy(
errorNotification = null,
onrampProviderState = OnrampV2ProvidersUM.Loading,
offersBlockState = OnrampOffersBlockUM.Loading(isBlockVisible = false),
amountBlockState = it.amountBlockState.copy(
secondaryFieldModel = OnrampNewAmountSecondaryFieldUM.Loading,
),
) ?: it
}
loadQuotes()
}
private suspend fun updatePairsAndQuotes() {
val state = state.value as? OnrampV2MainComponentUM.Content
if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) {
_state.update { amountStateFactory.getAmountSecondaryLoadingState() }
}
fetchPairsUseCase.invoke(userWallet, params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError,
ifRight = {
_state.update {
if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) {
return@fold
} else {
amountStateFactory.getAmountSecondaryResetState()
}
}
},
)
loadQuotes()
}
private fun loadQuotes() {
loadQuotesJob?.cancel()
loadQuotesJob = modelScope.launch {
runCatching {
val content = state.value as? OnrampV2MainComponentUM.Content ?: return@runCatching
if (content.amountBlockState.amountFieldModel.fiatAmount.value.isNullOrZero()) return@runCatching
fetchQuotesUseCase.invoke(
userWallet = userWallet,
amount = content.amountBlockState.amountFieldModel.fiatAmount,
cryptoCurrency = params.cryptoCurrency,
).onLeft(::handleOnrampError)
}
}
}
private fun handleOnrampError(onrampError: OnrampError) {
Timber.e(onrampError.toString())
_state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun updateProvider(quote: OnrampQuote) {
lastUpdateState.value = OnrampLastUpdate(
quote.fromAmount,
quote.countryCode,
)
_state.update {
amountStateFactory.getUpdatedProviderState(selectedQuote = quote)
}
}
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
quotes.forEach { errorState ->
when (errorState) {
is OnrampQuote.Error -> analyticsEventHandler.sendOnrampErrorEvent(
error = errorState.error,
tokenSymbol = params.cryptoCurrency.symbol,
providerName = errorState.provider.info.name,
paymentMethod = errorState.paymentMethod.name,
)
is OnrampQuote.AmountError -> analyticsEventHandler.sendOnrampErrorEvent(
error = errorState.error,
tokenSymbol = params.cryptoCurrency.symbol,
providerName = errorState.provider.info.name,
paymentMethod = errorState.paymentMethod.name,
)
else -> Unit
}
}
}
private fun checkLastInputState(quote: OnrampQuote?): Boolean {
return lastUpdateState.value?.lastAmount != quote?.fromAmount ||
lastUpdateState.value?.lastCountryString != quote?.countryCode
}
private fun sendScreenOpenAnalytics() {
analyticsEventHandler.send(
OnrampAnalyticsEvent.ScreenOpened(
source = params.source,
tokenSymbol = params.cryptoCurrency.symbol,
),
)
}
}

View file

@ -0,0 +1,130 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampAmountButtonUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2AmountButtonUMState
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampFooterContent(
state: OnrampV2MainComponentUM.Content,
boxScope: BoxScope,
modifier: Modifier = Modifier,
) {
val keyboardController = LocalSoftwareKeyboardController.current
boxScope.apply {
AnimatedVisibility(
modifier = Modifier
.imePadding()
.align(Alignment.BottomCenter),
visible = state.offersBlockState.isBlockVisible.not(),
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
label = "Footer block animation",
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = stringResourceSafe(id = R.string.common_continue),
onClick = {
state.continueButtonConfig.onClick()
keyboardController?.hide()
},
enabled = state.continueButtonConfig.enabled,
)
SpacerH(16.dp)
OnrampAmountButtons(state = state.onrampAmountButtonUMState)
}
}
}
}
@Composable
private fun OnrampAmountButtons(state: OnrampV2AmountButtonUMState) {
val keyboard by keyboardAsState()
AnimatedVisibility(
visible = state is OnrampV2AmountButtonUMState.Loaded,
enter = fadeIn(),
exit = fadeOut(),
) {
when (state) {
is OnrampV2AmountButtonUMState.Loaded -> {
if (keyboard is Keyboard.Opened) {
LazyRow(
modifier = Modifier.background(color = TangemTheme.colors.button.secondary),
contentPadding = PaddingValues(
vertical = 10.dp,
horizontal = 8.dp,
),
state = rememberLazyListState(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = state.amountButtons,
key = OnrampAmountButtonUM::value,
) {
AmountButton(button = it)
}
}
}
}
OnrampV2AmountButtonUMState.None -> Unit
}
}
}
@Composable
private fun AmountButton(button: OnrampAmountButtonUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.sizeIn(minHeight = 24.dp, minWidth = 62.dp)
.background(
color = TangemTheme.colors.field.primary,
shape = RoundedCornerShape(16.dp),
)
.clickable(onClick = button.onClick)
.padding(vertical = 4.dp, horizontal = 20.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "${button.value}${button.currency}",
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}

View file

@ -0,0 +1,141 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampNewMainScreen(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.systemBarsPadding(),
topBar = {
TangemTopAppBar(
startButton = state.topBarConfig.startButtonUM,
endButton = state.topBarConfig.endButtonUM,
title = state.topBarConfig.title.resolveReference(),
)
},
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
) { scaffoldPaddings ->
OnrampNewMainComponentContent(
state = state,
modifier = Modifier.padding(scaffoldPaddings),
)
}
}
@Composable
internal fun OnrampNewMainComponentContent(state: OnrampV2MainComponentUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is OnrampV2MainComponentUM.InitialLoading -> InitialLoading(state = state)
is OnrampV2MainComponentUM.Content -> Content(state = state)
}
}
if (state is OnrampV2MainComponentUM.Content) {
OnrampFooterContent(
state = state,
boxScope = this,
)
}
}
}
@Composable
private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContentLoading()
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
}
}
@Composable
private fun OnrampAmountContentLoading() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(size = TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action)
.padding(vertical = TangemTheme.dimens.spacing28),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.size(width = 76.dp, height = 20.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12)
.size(width = 136.dp, height = 44.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8)
.size(width = 52.dp, height = 16.dp),
radius = TangemTheme.dimens.radius4,
)
RectangleShimmer(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing20)
.size(width = 84.dp, height = 28.dp),
radius = TangemTheme.dimens.radius14,
)
}
}
@Composable
private fun Content(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.navigationBarsPadding()
.padding(
bottom = 76.dp,
start = 16.dp,
end = 16.dp,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampV2AmountContent(state = state)
OnrampOffersContent(state = state.offersBlockState)
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.mainv2.entity.OnrampOffersBlockUM
@Composable
internal fun OnrampOffersContent(state: OnrampOffersBlockUM) {
AnimatedVisibility(
visible = state.isBlockVisible,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(durationMillis = 300),
),
label = "Offers block animation",
) {
Text(
text = "Some offers",
style = TangemTheme.typography.head,
)
// TODO in [REDACTED_TASK_KEY] to be continued
}
}

View file

@ -0,0 +1,199 @@
package com.tangem.features.onramp.mainv2.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
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.BuyTokenDetailsScreenTestTags
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampNewAmountSecondaryFieldUM
import com.tangem.features.onramp.mainv2.entity.OnrampNewCurrencyUM
import com.tangem.features.onramp.mainv2.entity.OnrampV2MainComponentUM
@Composable
internal fun OnrampV2AmountContent(state: OnrampV2MainComponentUM.Content, modifier: Modifier = Modifier) {
val padding = remember(state.offersBlockState.isBlockVisible) {
if (state.offersBlockState.isBlockVisible) {
22.dp
} else {
46.dp
}
}
Column(
modifier = modifier
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(size = TangemTheme.dimens.radius16),
)
.padding(vertical = padding)
.animateContentSize(animationSpec = tween(durationMillis = 300)),
horizontalAlignment = Alignment.CenterHorizontally,
) {
OnrampHeaderTitle()
SpacerH(12.dp)
OnrampAmountField(
amountField = state.amountBlockState.amountFieldModel,
currencyCode = state.amountBlockState.currencyUM.code,
)
SpacerH(8.dp)
OnrampAmountSecondary(state = state.amountBlockState.secondaryFieldModel)
SpacerH(20.dp)
OnrampCurrencyIcon(currencyUM = state.amountBlockState.currencyUM)
}
}
@Composable
private fun OnrampHeaderTitle() {
Text(
text = stringResourceSafe(R.string.onramp_you_will_pay_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: String) {
val decimalFormat = rememberDecimalFormat()
val requester = remember { FocusRequester() }
AmountTextField(
value = amountField.fiatValue,
decimals = amountField.fiatAmount.decimals,
visualTransformation = AmountVisualTransformation(
decimals = amountField.fiatAmount.decimals,
symbol = currencyCode,
currencyCode = currencyCode,
decimalFormat = decimalFormat,
symbolColor = TangemTheme.colors.text.disabled,
),
onValueChange = amountField.onValueChange,
keyboardOptions = amountField.keyboardOptions,
keyboardActions = amountField.keyboardActions,
textStyle = TangemTheme.typography.head.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
isEnabled = !amountField.isError,
isAutoResize = true,
isValuePasted = amountField.isValuePasted,
onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss,
modifier = Modifier
.focusRequester(requester)
.padding(
top = TangemTheme.dimens.spacing24,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)
.requiredHeightIn(min = TangemTheme.dimens.size32)
.testTag(BuyTokenDetailsScreenTestTags.FIAT_AMOUNT_TEXT_FIELD),
)
LaunchedEffect(key1 = Unit) {
requester.requestFocus()
}
}
@Composable
private fun OnrampAmountSecondary(state: OnrampNewAmountSecondaryFieldUM) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)
.testTag(BuyTokenDetailsScreenTestTags.TOKEN_AMOUNT),
contentAlignment = Alignment.Center,
) {
when (state) {
is OnrampNewAmountSecondaryFieldUM.Content -> Text(
text = state.amount.resolveReference(),
style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
is OnrampNewAmountSecondaryFieldUM.Error -> Text(
text = state.error.resolveReference(),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
)
is OnrampNewAmountSecondaryFieldUM.Loading -> TextShimmer(
style = TangemTheme.typography.caption2,
modifier = Modifier.width(TangemTheme.dimens.size62),
)
}
}
}
@Composable
private fun OnrampCurrencyIcon(currencyUM: OnrampNewCurrencyUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(14.dp))
.background(TangemTheme.colors.button.secondary)
.clickable(onClick = currencyUM.onClick)
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AsyncImage(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.testTag(BuyTokenDetailsScreenTestTags.FIAT_CURRENCY_ICON),
model = currencyUM.iconUrl,
contentDescription = null,
)
Text(
text = currencyUM.code,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
)
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.testTag(BuyTokenDetailsScreenTestTags.EXPAND_FIAT_LIST_BUTTON),
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}

View file

@ -81,6 +81,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
),
)
},
launchSepa = params.launchSepa,
),
)
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(

View file

@ -129,6 +129,9 @@ internal class FeeSelectorModel @Inject constructor(
)
}
uiState.update(FeeItemSelectedTransformer(feeItem))
if (feeItem !is FeeItem.Custom) {
onDoneClick()
}
}
override fun onCustomFeeValueChange(index: Int, value: String) {

View file

@ -82,15 +82,19 @@ internal fun FeeSelectorModalBottomSheet(
modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp),
)
},
footer = {
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
enabled = state.isPrimaryButtonEnabled,
text = stringResourceSafe(R.string.common_done),
onClick = feeSelectorIntents::onDoneClick,
)
footer = if (state.selectedFeeItem is FeeItem.Custom) {
{
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
enabled = state.isPrimaryButtonEnabled,
text = stringResourceSafe(R.string.common_done),
onClick = feeSelectorIntents::onDoneClick,
)
}
} else {
null
},
)
}
@ -426,7 +430,14 @@ private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider<
FeeItem.Fast(fee = Fee.Common(Amount(value = BigDecimal("0.03"), blockchain = Blockchain.Ethereum))),
customFeeItem,
),
selectedFeeItem = customFeeItem,
selectedFeeItem = FeeItem.Slow(
fee = Fee.Common(
Amount(
value = BigDecimal("0.01"),
blockchain = Blockchain.Ethereum,
),
),
),
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = true,
isFeeConvertibleToFiat = true,

View file

@ -21,14 +21,12 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
@ -90,7 +88,7 @@ internal class SendConfirmModel @Inject constructor(
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
@ -329,15 +327,9 @@ internal class SendConfirmModel @Inject constructor(
),
)
if (userWallet is UserWallet.Hot) {
return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow
}
val cardInfo =
getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return
modelScope.launch {
sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo))
val metaInfo = getWalletMetaInfoUseCase.invoke(userWallet.walletId).getOrNull() ?: return@launch
sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo))
}
}

View file

@ -18,7 +18,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
@ -26,7 +26,6 @@ import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
@ -91,7 +90,7 @@ internal class SendModel @Inject constructor(
private val parseQrCodeUseCase: ParseQrCodeUseCase,
private val sendConfirmAlertFactory: SendConfirmAlertFactory,
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
@ -472,15 +471,9 @@ internal class SendModel @Inject constructor(
),
)
if (userWallet is UserWallet.Hot) {
return // TODO [REDACTED_TASK_KEY] [Hot Wallet] Email feedback flow
}
val cardInfo =
getCardInfoUseCase(userWallet.requireColdWallet().scanResponse).getOrNull() ?: return
modelScope.launch {
sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo))
val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch
sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(walletMetaInfo = metaInfo))
}
}

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