Updated on 2026-08-14

This commit is contained in:
Tangem 2024-03-19 18:11:56 +00:00
commit b76e1bbc45
219 changed files with 4585 additions and 5271 deletions

View file

@ -2,6 +2,7 @@ package com.tangem.managetokens.presentation.addcustomtoken.state.factory
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.tokens.error.AddCustomTokenError
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
@ -15,6 +16,7 @@ import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
@Suppress("LargeClass")
internal class AddCustomTokenStateFactory(
private val currentStateProvider: Provider<AddCustomTokenState>,
private val clickIntents: AddCustomTokenClickIntents,
@ -72,8 +74,11 @@ internal class AddCustomTokenStateFactory(
)
}
private fun getListOfDerivations(supportedNetworks: List<Network>): List<Derivation> {
return supportedNetworks.mapNotNull { network ->
private fun getListOfDerivations(
networksListToGenerateDerivations: List<Network>,
filterOnlyHardenedDerivations: Boolean = false,
): List<Derivation> {
return networksListToGenerateDerivations.mapNotNull { network ->
network.derivationPath.value?.let { rawPath ->
Derivation(
networkName = network.name,
@ -82,6 +87,12 @@ internal class AddCustomTokenStateFactory(
networkId = network.backendId,
onDerivationSelected = clickIntents::onDerivationSelected,
)
}.takeIf { derivation ->
if (filterOnlyHardenedDerivations) {
derivation?.let { allNodesHardened(createDerivationPathOrNull(it.path)) } ?: false
} else {
true
}
}
}
}
@ -218,41 +229,52 @@ internal class AddCustomTokenStateFactory(
)
}
fun updateStateOnNetworkSelected(networkItemState: NetworkItemState, supportsTokens: Boolean): AddCustomTokenState {
fun updateStateOnNetworkSelected(
networkItemState: NetworkItemState,
supportsTokens: Boolean,
networks: List<Network>,
requiresHardenedDerivationOnly: Boolean,
): AddCustomTokenState {
val uiState = currentStateProvider()
val tokenData = if (supportsTokens) {
uiState.tokenData
?: CustomTokenData(
contractAddressTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onTokenNameChange,
onFocusExit = clickIntents::onTokenNameFocusExit,
),
symbolTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onSymbolChange,
onFocusExit = clickIntents::onSymbolFocusExit,
),
decimalsTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onDecimalsChange,
onFocusExit = clickIntents::onDecimalsFocusExit,
),
)
uiState.tokenData ?: CustomTokenData(
contractAddressTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onTokenNameChange,
onFocusExit = clickIntents::onTokenNameFocusExit,
),
symbolTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onSymbolChange,
onFocusExit = clickIntents::onSymbolFocusExit,
),
decimalsTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onDecimalsChange,
onFocusExit = clickIntents::onDecimalsFocusExit,
),
)
} else {
null
}
val derivations = getListOfDerivations(networks, requiresHardenedDerivationOnly)
val chooseDerivationState = createChooseDerivationState(derivations)
return uiState.copy(
chooseNetworkState = uiState.chooseNetworkState.copy(selectedNetwork = networkItemState),
chooseNetworkState = uiState.chooseNetworkState.copy(
selectedNetwork = networkItemState,
),
chooseDerivationState = chooseDerivationState,
tokenData = tokenData,
addTokenButton = uiState.addTokenButton.copy(isEnabled = true),
)
@ -291,6 +313,47 @@ internal class AddCustomTokenStateFactory(
)
}
fun updateOnCustomDerivationEntered(input: String, requiresHardenedDerivationOnly: Boolean): AddCustomTokenState {
val uiState = currentStateProvider()
val path = createDerivationPathOrNull(input)
val isWrongDerivationForWallet2 = isWrongDerivationForWallet2(
requiresHardenedDerivationOnly = requiresHardenedDerivationOnly,
derivationPath = path,
)
val enterDerivationState = uiState.chooseDerivationState?.enterCustomDerivationState?.copy(
confirmButtonEnabled = path != null && !isWrongDerivationForWallet2,
derivationIncorrect = input.isNotBlank() && path == null || isWrongDerivationForWallet2,
)
return uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
enterCustomDerivationState = enterDerivationState,
),
)
}
private fun isWrongDerivationForWallet2(
requiresHardenedDerivationOnly: Boolean,
derivationPath: DerivationPath?,
): Boolean {
return if (requiresHardenedDerivationOnly) {
!allNodesHardened(derivationPath)
} else {
false
}
}
private fun allNodesHardened(derivationPath: DerivationPath?): Boolean {
return derivationPath?.nodes?.all { it.isHardened } ?: false
}
private fun createDerivationPathOrNull(rawPath: String): DerivationPath? {
return try {
DerivationPath(rawPath)
} catch (error: Throwable) {
null
}
}
fun updateStateOnLoadingTokenInfo(contractAddress: String): AddCustomTokenState {
return currentStateProvider().copy(
tokenData = CustomTokenData(

View file

@ -9,8 +9,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.HDWalletError
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.CryptoCurrency
@ -56,6 +54,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
private val validateContractAddressUseCase: ValidateContractAddressUseCase,
private val getNetworksSupportedByWallet: GetNetworksSupportedByWallet,
private val areTokensSupportedByNetworkUseCase: AreTokensSupportedByNetworkUseCase,
private val requiresHardenedDerivationOnlyUseCase: RequiresHardenedDerivationOnlyUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel(), AddCustomTokenClickIntents, DefaultLifecycleObserver {
@ -135,13 +134,25 @@ internal class AddCustomTokenViewModel @Inject constructor(
private fun selectNetwork(networkItemState: NetworkItemState) {
viewModelScope.launch(dispatchers.io) {
val selectedWalletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId
// TODO [REDACTED_TASK_KEY]
val selectedWalletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId ?: return@launch
val supportsTokens = areTokensSupportedByNetworkUseCase(
networkId = networkItemState.id,
userWalletId = selectedWalletId,
).getOrNull() ?: false
val networksForDerivations = getSupportedNetworks(selectedWalletId)
withContext(dispatchers.main) {
uiState = stateFactory.updateStateOnNetworkSelected(networkItemState, supportsTokens)
uiState = stateFactory.updateStateOnNetworkSelected(
networkItemState = networkItemState,
supportsTokens = supportsTokens,
networks = networksForDerivations,
requiresHardenedDerivationOnly = requiresHardenedDerivationOnly(
networkId = networkItemState.id,
userWalletId = selectedWalletId,
),
)
}
}
}
@ -347,6 +358,8 @@ internal class AddCustomTokenViewModel @Inject constructor(
}
override fun onCustomDerivationChange(input: String) {
val selectedWallet = getSelectedWalletSyncUseCase().getOrNull() ?: return
analyticsEventHandler.send(ManageTokens.CustomTokenDerivationSelected(ManageTokens.Derivation.CUSTOM.value))
uiState = uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
@ -356,25 +369,19 @@ internal class AddCustomTokenViewModel @Inject constructor(
),
)
debouncer.debounce(waitMs = DEFAULT_WAIT_TIME_MS, coroutineScope = viewModelScope + dispatchers.io) {
val path = createDerivationPathOrNull(input)
val enterDerivationState = uiState.chooseDerivationState?.enterCustomDerivationState?.copy(
confirmButtonEnabled = path != null,
derivationIncorrect = input.isNotBlank() && path == null,
)
uiState = uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
enterCustomDerivationState = enterDerivationState,
),
val networkId = uiState.chooseNetworkState.selectedNetwork?.id ?: return@debounce
uiState = stateFactory.updateOnCustomDerivationEntered(
input = input,
requiresHardenedDerivationOnly = requiresHardenedDerivationOnly(networkId, selectedWallet.walletId),
)
}
}
private fun createDerivationPathOrNull(rawPath: String): DerivationPath? {
return try {
DerivationPath(rawPath)
} catch (error: HDWalletError) {
null
}
private suspend fun requiresHardenedDerivationOnly(networkId: String, userWalletId: UserWalletId): Boolean {
return requiresHardenedDerivationOnlyUseCase.invoke(
networkId = networkId,
userWalletId = userWalletId,
).getOrElse { false }
}
override fun onCustomDerivationSelected() {

View file

@ -25,7 +25,7 @@ internal fun TokensList(
LazyColumn(modifier = modifier) {
item {
Text(
text = stringResource(id = R.string.manage_tokens_title),
text = stringResource(id = R.string.manage_tokens_list_header_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),

View file

@ -22,7 +22,9 @@ import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.TangemTextFieldsDefault
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.onboarding.R
import com.tangem.feature.onboarding.presentation.wallet2.model.ButtonState
import com.tangem.feature.onboarding.presentation.wallet2.model.ImportSeedPhraseState
import com.tangem.feature.onboarding.presentation.wallet2.model.TextFieldState
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.DescriptionSubTitleText
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingActionBlock
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingDescriptionBlock
@ -67,7 +69,7 @@ fun ImportSeedPhraseScreen(state: ImportSeedPhraseState, modifier: Modifier = Mo
PrimaryButtonIconEnd(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.onboarding_create_wallet_button_create_wallet),
text = stringResource(id = R.string.common_import),
iconResId = R.drawable.ic_tangem_24,
enabled = state.buttonCreateWallet.enabled,
showProgress = state.buttonCreateWallet.showProgress,
@ -196,6 +198,28 @@ private fun SuggestionsBlockPreview_Dark(
}
}
@Preview
@Composable
private fun ImportSeedPhraseScreenPreview_Light(
@PreviewParameter(SuggestionsPreviewParamsProvider::class) suggestions: ImmutableList<String>,
) {
TangemTheme(isDark = false) {
ImportSeedPhraseScreen(
ImportSeedPhraseState(
tvSeedPhrase = TextFieldState(onTextFieldValueChanged = {}),
onSuggestedPhraseClick = {},
buttonCreateWallet = ButtonState(
enabled = true,
isClickable = true,
showProgress = false,
onClick = {},
),
suggestionsList = suggestions,
),
)
}
}
private class SuggestionsPreviewParamsProvider : CollectionPreviewParameterProvider<ImmutableList<String>>(
collection = listOf(
persistentListOf(

View file

@ -1,5 +1,6 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@ -8,16 +9,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerW32
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.onboarding.R
import com.tangem.feature.onboarding.presentation.wallet2.model.ButtonState
import com.tangem.feature.onboarding.presentation.wallet2.model.MnemonicGridItem
import com.tangem.feature.onboarding.presentation.wallet2.model.YourSeedPhraseState
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.Description
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingActionBlock
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingDescriptionBlock
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
[REDACTED_AUTHOR]
@ -88,4 +92,25 @@ private fun PhraseGreedBlock(mnemonicGridItems: ImmutableList<MnemonicGridItem>)
}
}
}
}
@Preview
@Composable
private fun YourSeedPhraseScreenPreview_Light() {
TangemTheme(isDark = false) {
YourSeedPhraseScreen(
state = YourSeedPhraseState(
mnemonicGridItems = (1..12).map { MnemonicGridItem(it, it.toString()) }.toImmutableList(),
buttonContinue = ButtonState(
enabled = true,
isClickable = true,
showProgress = false,
onClick = {},
),
),
modifier = Modifier
.fillMaxSize()
.background(color = TangemTheme.colors.background.primary),
)
}
}

View file

@ -1,6 +0,0 @@
package com.tangem.feature.qrscanning
enum class SourceType {
WALLET_CONNECT,
SEND,
}

View file

@ -10,7 +10,6 @@ android {
namespace = "com.tangem.feature.qrscanning.impl"
}
dependencies {
/** Core */
@ -40,8 +39,13 @@ dependencies {
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Api */
implementation(projects.features.qrScanning.api)
/** Domain */
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
/** Compose */
implementation(deps.compose.foundation)
implementation(deps.compose.ui)

View file

@ -1,27 +0,0 @@
package com.tangem.feature.qrscanning.di
import com.tangem.feature.qrscanning.repo.DefaultQrScanningEventsRepository
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object QrScanningModule {
@Provides
@Singleton
fun provideQrScannedEventsRepository(): QrScanningEventsRepository {
return DefaultQrScanningEventsRepository()
}
@Provides
@Singleton
fun provideEmitQrScannedEventUseCase(repository: QrScanningEventsRepository): EmitQrScannedEventUseCase {
return EmitQrScannedEventUseCase(repository)
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.feature.qrscanning.SourceType
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.impl.R
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents

View file

@ -1,19 +0,0 @@
package com.tangem.feature.qrscanning.repo
import com.tangem.feature.qrscanning.SourceType
import kotlinx.coroutines.flow.*
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
private data class QrScanningEvent(val type: SourceType, val qrCode: String)
private val scannedEvents = MutableSharedFlow<QrScanningEvent>()
override suspend fun emitResult(type: SourceType, qrCode: String) {
scannedEvents.emit(QrScanningEvent(type, qrCode))
}
override fun subscribeToScanningResults(type: SourceType) = scannedEvents
.filter { it.type == type }
.map { it.qrCode }
}

View file

@ -1,11 +0,0 @@
package com.tangem.feature.qrscanning.repo
import com.tangem.feature.qrscanning.SourceType
import kotlinx.coroutines.flow.Flow
interface QrScanningEventsRepository {
suspend fun emitResult(type: SourceType, qrCode: String)
fun subscribeToScanningResults(type: SourceType): Flow<String>
}

View file

@ -1,20 +0,0 @@
package com.tangem.feature.qrscanning.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
internal class EmitQrScannedEventUseCase(
private val repository: QrScanningEventsRepository,
) {
suspend operator fun invoke(type: SourceType, qrCode: String): Either<Exception, Unit> {
return try {
repository.emitResult(type, qrCode)
Unit.right()
} catch (e: Exception) {
e.left()
}
}
}

View file

@ -1,22 +0,0 @@
package com.tangem.feature.qrscanning.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
import kotlinx.coroutines.flow.Flow
import java.lang.Exception
class ListenToQrScanningUseCase(
val repository: QrScanningEventsRepository,
) {
operator fun invoke(type: SourceType): Either<Exception, Flow<String>> {
return try {
repository.subscribeToScanningResults(type).right()
} catch (e: Exception) {
e.left()
}
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.feature.qrscanning.viewmodel
import androidx.activity.result.ActivityResultLauncher
import com.tangem.feature.qrscanning.SourceType
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import kotlinx.coroutines.CoroutineScope
import kotlin.properties.Delegates

View file

@ -1,8 +1,8 @@
package com.tangem.feature.qrscanning.viewmodel
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.DismissBottomSheetTransformer
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.launch

View file

@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.feature.qrscanning.QrScanningRouter.Companion.NETWORK_KEY
import com.tangem.feature.qrscanning.QrScanningRouter.Companion.SOURCE_KEY
import com.tangem.feature.qrscanning.SourceType
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.presentation.QrScanningStateController

View file

@ -27,6 +27,8 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.core.content.ContextCompat.startActivity
import com.tangem.core.ui.components.*
import com.tangem.core.ui.res.TangemTheme
@ -113,14 +115,22 @@ private fun Awards(expectedAwards: ExpectedAwards) {
thickness = TangemTheme.dimens.size0_5,
)
AwardText(
startText = stringResource(id = R.string.referral_expected_awards),
startText = if (expectedAwards.expectedAwards.isNotEmpty()) {
stringResource(id = R.string.referral_expected_awards)
} else {
stringResource(id = R.string.referral_no_expected_awards)
},
startTextColor = TangemTheme.colors.text.tertiary,
startTextStyle = TangemTheme.typography.subtitle2,
endText = pluralStringResource(
id = R.plurals.referral_number_of_wallets,
count = expectedAwards.numberOfWallets,
expectedAwards.numberOfWallets,
),
endText = if (expectedAwards.expectedAwards.isNotEmpty()) {
pluralStringResource(
id = R.plurals.referral_number_of_wallets,
count = expectedAwards.numberOfWallets,
expectedAwards.numberOfWallets,
)
} else {
""
},
endTextColor = TangemTheme.colors.text.tertiary,
endTextStyle = TangemTheme.typography.body2,
cornersToRound = CornersToRound.ZERO,
@ -309,34 +319,20 @@ private fun Context.shareText(text: String) {
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_ParticipateBottomBlock_InLightTheme() {
private fun ParticipateBottomBlockPreview_Light(
@PreviewParameter(ParticipateBottomBlockDataProvider::class) data: ParticipateBottomBlockData,
) {
TangemTheme(isDark = false) {
Column(Modifier.background(TangemTheme.colors.background.secondary)) {
ParticipateBottomBlock(
purchasedWalletCount = 3,
code = "x4JdK",
shareLink = "",
expectedAwards = ExpectedAwards(
numberOfWallets = 3,
expectedAwards = listOf(
ExpectedAward(
amount = "10 USDT",
paymentDate = "Today",
),
ExpectedAward(
amount = "20 USDT",
paymentDate = "6 Aug 2023",
),
ExpectedAward(
amount = "30 USDT",
paymentDate = "10 Aug 2023",
),
),
),
onAgreementClick = {},
onShowCopySnackbar = {},
onCopyClick = {},
onShareClick = {},
purchasedWalletCount = data.purchasedWalletCount,
code = data.code,
shareLink = data.shareLink,
expectedAwards = data.expectedAwards,
onAgreementClick = data.onAgreementClick,
onShowCopySnackbar = data.onShowCopySnackbar,
onCopyClick = data.onCopyClick,
onShareClick = data.onShareClick,
)
}
}
@ -344,18 +340,20 @@ private fun Preview_ParticipateBottomBlock_InLightTheme() {
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_ParticipateBottomBlock_Without_Awards_InLightTheme() {
TangemTheme(isDark = false) {
private fun ParticipateBottomBlockPreview_Dark(
@PreviewParameter(ParticipateBottomBlockDataProvider::class) state: ParticipateBottomBlockData,
) {
TangemTheme(isDark = true) {
Column(Modifier.background(TangemTheme.colors.background.secondary)) {
ParticipateBottomBlock(
purchasedWalletCount = 3,
code = "x4JdK",
shareLink = "",
expectedAwards = null,
onAgreementClick = {},
onShowCopySnackbar = {},
onCopyClick = {},
onShareClick = {},
purchasedWalletCount = state.purchasedWalletCount,
code = state.code,
shareLink = state.shareLink,
expectedAwards = state.expectedAwards,
onAgreementClick = state.onAgreementClick,
onShowCopySnackbar = state.onShowCopySnackbar,
onCopyClick = state.onCopyClick,
onShareClick = state.onShareClick,
)
}
}
@ -363,26 +361,7 @@ private fun Preview_ParticipateBottomBlock_Without_Awards_InLightTheme() {
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_ParticipateBottomBlock_Without_Awards_And_Purchased_Wallets_InLightTheme() {
TangemTheme(isDark = false) {
Column(Modifier.background(TangemTheme.colors.background.secondary)) {
ParticipateBottomBlock(
purchasedWalletCount = 0,
code = "x4JdK",
shareLink = "",
expectedAwards = null,
onAgreementClick = {},
onShowCopySnackbar = {},
onCopyClick = {},
onShareClick = {},
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun LessMoreButton_White() {
private fun LessMoreButton_Light() {
TangemTheme(isDark = false) {
LessMoreButton(
isExpanded = remember {
@ -394,19 +373,64 @@ private fun LessMoreButton_White() {
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_ParticipateBottomBlock_Without_Awards_InDarkTheme() {
private fun LessMoreButton_Dark() {
TangemTheme(isDark = true) {
Column(Modifier.background(TangemTheme.colors.background.secondary)) {
ParticipateBottomBlock(
purchasedWalletCount = 3,
code = "x4JdK",
shareLink = "",
expectedAwards = null,
onAgreementClick = {},
onShowCopySnackbar = {},
onCopyClick = {},
onShareClick = {},
)
}
LessMoreButton(
isExpanded = remember {
mutableStateOf(false)
},
)
}
}
}
private class ParticipateBottomBlockDataProvider : CollectionPreviewParameterProvider<ParticipateBottomBlockData>(
collection = listOf(
ParticipateBottomBlockData(
purchasedWalletCount = 3,
expectedAwards = ExpectedAwards(
numberOfWallets = 3,
expectedAwards = listOf(
ExpectedAward(
amount = "10 USDT",
paymentDate = "Today",
),
ExpectedAward(
amount = "20 USDT",
paymentDate = "6 Aug 2023",
),
ExpectedAward(
amount = "30 USDT",
paymentDate = "10 Aug 2023",
),
),
),
),
ParticipateBottomBlockData(
purchasedWalletCount = 3,
expectedAwards = ExpectedAwards(
numberOfWallets = 3,
expectedAwards = emptyList(),
),
),
ParticipateBottomBlockData(
purchasedWalletCount = 3,
expectedAwards = null,
),
ParticipateBottomBlockData(
purchasedWalletCount = 0,
expectedAwards = null,
),
),
)
private data class ParticipateBottomBlockData(
val purchasedWalletCount: Int,
val expectedAwards: ExpectedAwards?,
val code: String = "x4JDK",
val shareLink: String = "",
val onAgreementClick: () -> Unit = {},
val onShowCopySnackbar: () -> Unit = {},
val onCopyClick: () -> Unit = {},
val onShareClick: () -> Unit = {},
)

View file

@ -11,6 +11,7 @@ interface SendRouter {
const val USER_WALLET_ID_KEY = "send_user_wallet_id"
const val TRANSACTION_ID_KEY = "send_transaction_id"
const val AMOUNT_KEY = "send_amount"
const val TAG_KEY = "send_tag"
const val DESTINATION_ADDRESS_KEY = "send_destination_address"
}
}

View file

@ -69,12 +69,13 @@ dependencies {
implementation(projects.domain.card)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
/** Feature modules */
implementation(projects.features.send.api)
implementation(projects.features.tokendetails.api)
implementation(projects.features.qrScanning.api)
implementation(projects.features.qrScanning.impl)
/** DI */
implementation(deps.hilt.android)

View file

@ -5,10 +5,10 @@ import androidx.fragment.app.Fragment
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.core.navigation.ReduxNavController
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.qrscanning.SourceType
import com.tangem.features.send.impl.presentation.SendFragment
import com.tangem.features.tokendetails.navigation.TokenDetailsRouter

View file

@ -13,8 +13,8 @@ import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.usecase.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.state.StateRouter

View file

@ -23,12 +23,12 @@ internal sealed class SendNotification(val config: NotificationConfig) {
),
) {
object TotalExceedsBalance : Error(
data object TotalExceedsBalance : Error(
title = resourceReference(R.string.send_notification_exceed_balance_title),
subtitle = resourceReference(R.string.send_notification_exceed_balance_text),
)
object InvalidAmount : Error(
data object InvalidAmount : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(R.string.send_notification_invalid_amount_text),
)
@ -49,7 +49,7 @@ internal sealed class SendNotification(val config: NotificationConfig) {
val amountLimit: String,
val onConfirmClick: () -> Unit,
) : Error(
title = resourceReference(R.string.send_notifiaction_transaction_limit_title),
title = resourceReference(R.string.send_notification_transaction_limit_title),
subtitle = resourceReference(
R.string.send_notification_transaction_limit_text,
wrappedList(cryptoCurrency, utxoLimit, amountLimit),
@ -94,17 +94,6 @@ internal sealed class SendNotification(val config: NotificationConfig) {
subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)),
)
data class NetworkCoverage(
val amountReducedBy: String,
val amountReduced: String,
) : Warning(
title = resourceReference(id = R.string.send_network_fee_warning_title),
subtitle = resourceReference(
id = R.string.send_network_fee_warning_content,
formatArgs = wrappedList(amountReducedBy, amountReduced),
),
)
data object FeeTooLow : Warning(
title = resourceReference(id = R.string.send_notification_transaction_delay_title),
subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text),

View file

@ -52,7 +52,6 @@ internal class SendNotificationFactory(
addDustWarningNotification(feeAmount, sendAmount)
addTransactionLimitErrorNotification(feeAmount, sendAmount)
// warnings
addFeeCoverageNotification(sendState.isSubtract, sendAmount)
addExistentialWarningNotification(feeAmount, sendAmount)
addHighFeeWarningNotification(sendAmount, sendState.ignoreAmountReduce)
addTooLowNotification(feeState)
@ -257,29 +256,6 @@ internal class SendNotificationFactory(
}
}
private fun MutableList<SendNotification>.addFeeCoverageNotification(
isSubtract: Boolean,
amountValue: BigDecimal,
) {
val state = currentStateProvider()
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val feeAmount = state.feeState?.fee?.amount?.value ?: BigDecimal.ZERO
val amountReducedValue = amountValue.minus(feeAmount)
val amountReducedByValue = amountValue.minus(amountReducedValue)
val amountReducedBy = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amountReducedByValue,
cryptoCurrency = cryptoCurrency,
)
val amountReduced = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amountReducedValue,
cryptoCurrency = cryptoCurrency,
)
if (isSubtract) {
add(SendNotification.Warning.NetworkCoverage(amountReducedBy, amountReduced))
}
}
private fun MutableList<SendNotification>.addTooLowNotification(feeState: SendStates.FeeState) {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return

View file

@ -5,6 +5,7 @@ import com.tangem.blockchain.common.TransactionData
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
@ -31,6 +32,7 @@ internal class SendStateFactory(
private val userWalletProvider: Provider<UserWallet>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
) {
@ -62,7 +64,7 @@ internal class SendStateFactory(
private val feeStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendFeeStateConverter(
appCurrencyProvider = appCurrencyProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
@ -85,16 +87,18 @@ internal class SendStateFactory(
val state = currentStateProvider()
return state.copy(
amountState = state.amountState ?: amountStateConverter.convert(""),
recipientState = state.recipientState ?: recipientStateConverter.convert(""),
recipientState = state.recipientState
?: recipientStateConverter.convert(SendRecipientStateConverter.Data("", null)),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
)
}
fun getReadyState(amount: String, destinationAddress: String): SendUiState {
fun getReadyState(amount: String, destinationAddress: String, memo: String?): SendUiState {
val state = currentStateProvider()
return state.copy(
amountState = state.amountState ?: amountStateConverter.convert(amount),
recipientState = state.recipientState ?: recipientStateConverter.convert(destinationAddress),
recipientState = state.recipientState
?: recipientStateConverter.convert(SendRecipientStateConverter.Data(destinationAddress, memo)),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
isEditingDisabled = true,
)
@ -214,9 +218,20 @@ internal class SendStateFactory(
//endregion
//region send
fun onSubtractSelect(isSubtract: Boolean): SendUiState {
fun onSubtractSelect(isSubtract: Boolean, isAmountSubtractAvailable: Boolean): SendUiState {
val state = currentStateProvider()
val fee = state.feeState?.fee ?: return state
val amountState = state.amountState ?: return state
val amount = amountState.amountTextField.cryptoAmount
val amountValue = amount.value ?: return state
val amountToSend = if (isSubtract && isAmountSubtractAvailable) {
val feeValue = fee.amount.value ?: return state
amountValue.minus(feeValue)
} else {
amountValue
}
return state.copy(
amountState = amountStateConverter.convert(amountToSend.parseBigDecimal(amount.decimals)),
sendState = state.sendState.copy(isSubtract = isSubtract),
)
}

View file

@ -14,6 +14,7 @@ import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldConverter
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.persistentListOf
internal class SendAmountStateConverter(
@ -37,18 +38,22 @@ internal class SendAmountStateConverter(
tokenIconState = iconStateConverter.convert(status),
amountTextField = sendAmountFieldConverter.convert(value),
isPrimaryButtonEnabled = false,
segmentedButtonConfig = persistentListOf(
SendAmountSegmentedButtonsConfig(
title = stringReference(status.currency.symbol),
iconState = iconStateConverter.convert(status),
isFiat = false,
),
SendAmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconUrl = appCurrency.iconSmallUrl,
isFiat = true,
),
),
segmentedButtonConfig = if (status.value.fiatRate.isNullOrZero()) {
persistentListOf()
} else {
persistentListOf(
SendAmountSegmentedButtonsConfig(
title = stringReference(status.currency.symbol),
iconState = iconStateConverter.convert(status),
isFiat = false,
),
SendAmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconUrl = appCurrency.iconSmallUrl,
isFiat = true,
),
)
},
)
}
}

View file

@ -13,14 +13,14 @@ import com.tangem.utils.converter.Converter
internal class FeeConverter(
private val clickIntents: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<FeeSelectorState.Content, Fee> {
private val ethereumCustomFeeConverter by lazy {
EthereumCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}

View file

@ -20,7 +20,7 @@ import kotlinx.collections.immutable.persistentListOf
internal class FeeStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
) {
@ -28,7 +28,7 @@ internal class FeeStateFactory(
SendFeeCustomFieldConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
@ -36,7 +36,7 @@ internal class FeeStateFactory(
FeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
@ -140,7 +140,7 @@ internal class FeeStateFactory(
}
private fun isFeeApproximate(fee: Fee): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoCurrencyStatus = feeCryptoCurrencyStatusProvider()
return isFeeApproximateUseCase(
networkId = cryptoCurrencyStatus.currency.network.id,
amountType = fee.amount.type,

View file

@ -14,14 +14,14 @@ import kotlinx.collections.immutable.persistentListOf
internal class SendFeeCustomFieldConverter(
private val clickIntents: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Fee, ImmutableList<SendTextField.CustomFee>> {
private val ethereumCustomFeeConverter by lazy {
EthereumCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}

View file

@ -9,16 +9,15 @@ import kotlinx.collections.immutable.persistentListOf
internal class SendFeeStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Unit, SendStates.FeeState> {
override fun convert(value: Unit): SendStates.FeeState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
return SendStates.FeeState(
feeSelectorState = FeeSelectorState.Loading,
fee = null,
notifications = persistentListOf(),
rate = cryptoCurrencyStatus.value.fiatRate,
rate = feeCryptoCurrencyStatusProvider().value.fiatRate,
appCurrency = appCurrencyProvider(),
isFeeApproximate = false,
)

View file

@ -28,7 +28,7 @@ import java.math.RoundingMode
internal class EthereumCustomFeeConverter(
private val clickIntents: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Fee.Ethereum, ImmutableList<SendTextField.CustomFee>> {
override fun convert(value: Fee.Ethereum): ImmutableList<SendTextField.CustomFee> {
@ -152,7 +152,7 @@ internal class EthereumCustomFeeConverter(
private fun getFeeFormatted(fee: BigDecimal?): TextReference {
val appCurrency = appCurrencyProvider()
val rate = cryptoCurrencyStatusProvider().value.fiatRate
val rate = feeCryptoCurrencyStatusProvider().value.fiatRate
val fiatFee = rate?.let { fee?.multiply(it) }
return stringReference(
BigDecimalFormatter.formatFiatAmount(
@ -164,7 +164,7 @@ internal class EthereumCustomFeeConverter(
}
private fun checkExceedBalance(feeAmount: BigDecimal?): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoCurrencyStatus = feeCryptoCurrencyStatusProvider()
val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
return feeAmount == null || feeAmount.isZero() || feeAmount > currencyCryptoAmount

View file

@ -31,10 +31,11 @@ internal class SendAmountFieldConverter(
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoDecimal = value.toBigDecimalOrDefault()
val cryptoAmount = cryptoDecimal.convertToAmount(cryptoCurrencyStatus.currency)
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val fiatValue = if (value.isEmpty()) {
""
} else {
val fiatDecimal = cryptoCurrencyStatus.value.fiatRate?.multiply(cryptoDecimal) ?: BigDecimal.ZERO
val fiatDecimal = fiatRate?.multiply(cryptoDecimal) ?: BigDecimal.ZERO
fiatDecimal.parseBigDecimal(FIAT_DECIMALS)
}
val isDoneActionEnabled = !cryptoDecimal.isZero()
@ -52,6 +53,7 @@ internal class SendAmountFieldConverter(
fiatAmount = getAppCurrencyAmount(appCurrencyProvider()),
isError = false,
error = TextReference.Res(R.string.swapping_insufficient_funds),
isFiatUnavailable = fiatRate == null,
)
}

View file

@ -27,6 +27,7 @@ internal sealed class SendTextField {
val fiatAmount: Amount,
val isFiatValue: Boolean,
val fiatValue: String,
val isFiatUnavailable: Boolean,
val isError: Boolean,
val error: TextReference,
) : SendTextField()

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.state.recipient
import androidx.annotation.StringRes
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@ -15,13 +16,18 @@ import com.tangem.utils.converter.Converter
internal class SendRecipientMemoFieldConverter(
private val clickIntents: SendClickIntents,
private val cryptoCurrencyStatus: Provider<CryptoCurrencyStatus>,
) : Converter<Int, SendTextField.RecipientMemo> {
) : Converter<SendRecipientMemoFieldConverter.Data, SendTextField.RecipientMemo> {
fun convertOrNull(): SendTextField.RecipientMemo? {
fun convertOrNull(memoValue: String?): SendTextField.RecipientMemo? {
val cryptoCurrency = cryptoCurrencyStatus().currency
val memo = memoValue ?: ""
return when (cryptoCurrency.network.id.value) {
Blockchain.XRP.id -> convert(R.string.send_destination_tag_field)
Blockchain.XRP.id -> {
convert(
value = Data(memo = memo, label = R.string.send_destination_tag_field),
)
}
Blockchain.Binance.id,
Blockchain.TON.id,
Blockchain.Cosmos.id,
@ -30,24 +36,30 @@ internal class SendRecipientMemoFieldConverter(
Blockchain.Stellar.id,
Blockchain.Hedera.id,
Blockchain.Algorand.id,
-> convert(R.string.send_extras_hint_memo)
-> {
convert(
value = Data(memo = memo, label = R.string.send_extras_hint_memo),
)
}
else -> null
}
}
override fun convert(value: Int): SendTextField.RecipientMemo {
override fun convert(value: Data): SendTextField.RecipientMemo {
return SendTextField.RecipientMemo(
value = "",
value = value.memo,
onValueChange = clickIntents::onRecipientMemoValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Text,
),
placeholder = resourceReference(R.string.send_optional_field),
label = resourceReference(value),
label = resourceReference(value.label),
error = resourceReference(R.string.send_memo_destination_tag_error),
disabledText = resourceReference(R.string.send_additional_field_already_included),
isEnabled = true,
)
}
data class Data(val memo: String, @StringRes val label: Int)
}

View file

@ -10,7 +10,7 @@ import kotlinx.collections.immutable.persistentListOf
internal class SendRecipientStateConverter(
private val clickIntents: SendClickIntents,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<String, SendStates.RecipientState> {
) : Converter<SendRecipientStateConverter.Data, SendStates.RecipientState> {
private val addressFieldConverter by lazy { SendRecipientAddressFieldConverter(clickIntents) }
private val memoFieldConverter by lazy {
@ -20,14 +20,16 @@ internal class SendRecipientStateConverter(
)
}
override fun convert(value: String): SendStates.RecipientState {
override fun convert(value: Data): SendStates.RecipientState {
return SendStates.RecipientState(
addressTextField = addressFieldConverter.convert(value),
memoTextField = memoFieldConverter.convertOrNull(),
addressTextField = addressFieldConverter.convert(value.address),
memoTextField = memoFieldConverter.convertOrNull(value.memo),
network = cryptoCurrencyStatusProvider().currency.network.name,
isPrimaryButtonEnabled = false,
wallets = persistentListOf(),
recent = persistentListOf(),
)
}
data class Data(val address: String, val memo: String? = null)
}

View file

@ -11,10 +11,12 @@ import androidx.compose.ui.Alignment.Companion.BottomCenter
import androidx.compose.ui.Alignment.Companion.TopCenter
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.defaultFormat
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
@ -62,10 +64,14 @@ internal fun AmountField(sendField: SendTextField.AmountField, isFiat: Boolean)
end = TangemTheme.dimens.spacing12,
),
) {
val text = "${secondaryValue.ifEmpty { decimalFormat.defaultFormat() }} ${secondaryAmount.currencySymbol}"
val text = if (sendField.isFiatUnavailable) {
BigDecimalFormatter.EMPTY_BALANCE_SIGN
} else {
"${secondaryValue.ifEmpty { decimalFormat.defaultFormat() }} ${secondaryAmount.currencySymbol}"
}
Text(
text = text,
style = TangemTheme.typography.caption2,
style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier

View file

@ -7,15 +7,17 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.currency.fiaticon.FiatIcon
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.amount.SendAmountSegmentedButtonsConfig
@ -28,6 +30,7 @@ internal fun SendAmountContent(
clickIntents: SendClickIntents,
) {
if (amountState == null) return
val hapticFeedback = LocalHapticFeedback.current
Column(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary),
@ -41,19 +44,29 @@ internal fun SendAmountContent(
end = TangemTheme.dimens.spacing16,
),
) {
SegmentedButtons(
modifier = Modifier
.weight(1f)
.height(TangemTheme.dimens.size40),
config = amountState.segmentedButtonConfig,
showIndication = false,
onClick = { clickIntents.onCurrencyChangeClick(it.isFiat) },
) {
SendAmountCurrencyButton(it)
if (amountState.segmentedButtonConfig.isNotEmpty()) {
SegmentedButtons(
modifier = Modifier
.weight(1f)
.height(TangemTheme.dimens.size40),
config = amountState.segmentedButtonConfig,
showIndication = false,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
clickIntents.onCurrencyChangeClick(it.isFiat)
},
) {
SendAmountCurrencyButton(it)
}
} else {
SpacerWMax()
}
SecondaryButton(
text = stringResource(R.string.send_max_amount),
onClick = clickIntents::onMaxValueClick,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
clickIntents.onMaxValueClick()
},
size = TangemButtonSize.Text,
shape = RoundedCornerShape(TangemTheme.dimens.radius26),
modifier = Modifier

View file

@ -18,7 +18,6 @@ import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
private const val FEE_SELECTOR_KEY = "FEE_SELECTOR_KEY"
private const val FEE_CUSTOM_KEY = "FEE_CUSTOM_KEY"
@ -37,9 +36,8 @@ internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: S
),
) {
feeSelector(state, clickIntents)
topNotifications(notifications)
customFee(feeSendState)
middleNotifications(notifications)
notifications(notifications)
}
}
@ -56,29 +54,6 @@ private fun LazyListScope.feeSelector(state: SendStates.FeeState, clickIntents:
}
}
private fun LazyListScope.topNotifications(
configs: ImmutableList<SendFeeNotification>,
modifier: Modifier = Modifier,
) {
notifications(
configs = configs.filter {
it is SendFeeNotification.Error.ExceedsBalance ||
it is SendFeeNotification.Warning.NetworkFeeUnreachable
}.toImmutableList(),
modifier = modifier,
)
}
private fun LazyListScope.middleNotifications(
configs: ImmutableList<SendFeeNotification>,
modifier: Modifier = Modifier,
) {
notifications(
configs = configs.filterIsInstance<SendFeeNotification.Warning.TooHigh>().toImmutableList(),
modifier = modifier,
)
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.notifications(
configs: ImmutableList<SendFeeNotification>,

View file

@ -12,9 +12,12 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
@ -30,7 +33,10 @@ import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.*
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.domain.wallets.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.wallets.usecase.ValidateWalletMemoUseCase
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
@ -66,17 +72,19 @@ internal class SendViewModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase,
private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val createTransactionUseCase: CreateTransactionUseCase,
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
private val parseSharedAddressUseCase: ParseSharedAddressUseCase,
private val walletManagersFacade: WalletManagersFacade,
private val reduxStateHolder: ReduxStateHolder,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val parseQrCodeUseCase: ParseQrCodeUseCase,
currencyChecksRepository: CurrencyChecksRepository,
isFeeApproximateUseCase: IsFeeApproximateUseCase,
getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
@ -95,6 +103,7 @@ internal class SendViewModel @Inject constructor(
private val transactionId: String? = savedStateHandle[SendRouter.TRANSACTION_ID_KEY]
private val amount: String? = savedStateHandle[SendRouter.AMOUNT_KEY]
private val destinationAddress: String? = savedStateHandle[SendRouter.DESTINATION_ADDRESS_KEY]
private val memo: String? = savedStateHandle[SendRouter.TAG_KEY]
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
@ -108,6 +117,7 @@ internal class SendViewModel @Inject constructor(
userWalletProvider = Provider { userWallet },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
feeCryptoCurrencyStatusProvider = Provider { feeCryptoCurrencyStatus },
validateWalletMemoUseCase = validateWalletMemoUseCase,
getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase,
)
@ -120,7 +130,7 @@ internal class SendViewModel @Inject constructor(
private val feeStateFactory = FeeStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
feeCryptoCurrencyStatusProvider = Provider { feeCryptoCurrencyStatus },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
isFeeApproximateUseCase = isFeeApproximateUseCase,
)
@ -165,12 +175,14 @@ internal class SendViewModel @Inject constructor(
private var isAmountSubtractAvailable: Boolean = false
private var coinCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var feeCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var balanceJobHolder = JobHolder()
private var balanceHidingJobHolder = JobHolder()
private var recipientsJobHolder = JobHolder()
private var feeJobHolder = JobHolder()
private var addressValidationJobHolder = JobHolder()
private var memoValidationJobHolder = JobHolder()
private var sendNotificationsJobHolder = JobHolder()
private var feeNotificationsJobHolder = JobHolder()
private var qrScannerJobHolder = JobHolder()
@ -204,7 +216,13 @@ internal class SendViewModel @Inject constructor(
ifRight = { wallet ->
userWallet = wallet
checkIfSubtractAvailable()
getCurrenciesStatusUpdates(wallet)
val isSingleWalletWithToken = wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
val isMultiCurrency = wallet.isMultiCurrency
getCurrenciesStatusUpdates(
isSingleWalletWithToken = isSingleWalletWithToken,
isMultiCurrency = isMultiCurrency,
)
},
ifLeft = {
uiState = eventStateFactory.getGenericErrorState(
@ -227,31 +245,38 @@ internal class SendViewModel @Inject constructor(
.saveIn(balanceHidingJobHolder)
}
private fun getCurrenciesStatusUpdates(wallet: UserWallet) {
val isSingleWallet = wallet.scanResponse.walletData?.token != null && !wallet.isMultiCurrency
private fun getCurrenciesStatusUpdates(isSingleWalletWithToken: Boolean, isMultiCurrency: Boolean) {
if (cryptoCurrency is CryptoCurrency.Coin) {
getCurrencyStatusUpdates(isSingleWallet = isSingleWallet)
.onEach { currencyStatus ->
currencyStatus.onRight {
onDataLoaded(
currencyStatus = it,
coinCurrencyStatus = it,
)
}
getCurrencyStatusUpdates(
isSingleWalletWithToken = isSingleWalletWithToken,
isMultiCurrency = isMultiCurrency,
).onEach { currencyStatus ->
currencyStatus.onRight {
onDataLoaded(
currencyStatus = it,
coinCurrencyStatus = it,
feeCurrencyStatus = getFeeCurrencyStatusSync(it, isMultiCurrency),
)
}
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
.saveIn(balanceJobHolder)
} else {
combine(
flow = getCoinCurrencyStatusUpdates(isSingleWallet = isSingleWallet),
flow2 = getCurrencyStatusUpdates(isSingleWallet = isSingleWallet),
) { coinStatus, currencyStatus ->
if (coinStatus.isRight() && currencyStatus.isRight()) {
flow = getCoinCurrencyStatusUpdates(isSingleWalletWithToken),
flow2 = getCurrencyStatusUpdates(
isSingleWalletWithToken = isSingleWalletWithToken,
isMultiCurrency = isMultiCurrency,
),
) { maybeCoinStatus, maybeCurrencyStatus ->
if (maybeCoinStatus.isRight() && maybeCurrencyStatus.isRight()) {
val currencyStatus = maybeCurrencyStatus.getOrElse { error("Currency status is unreachable") }
val coinStatus = maybeCoinStatus.getOrElse { error("Coin status is unreachable") }
onDataLoaded(
currencyStatus = currencyStatus.getOrElse { error("Currency status is unreachable") },
coinCurrencyStatus = coinStatus.getOrElse { error("Coin status is unreachable") },
currencyStatus = currencyStatus,
coinCurrencyStatus = coinStatus,
feeCurrencyStatus = getFeeCurrencyStatusSync(currencyStatus, isMultiCurrency),
)
}
}
@ -261,18 +286,41 @@ internal class SendViewModel @Inject constructor(
}
}
private fun getCoinCurrencyStatusUpdates(isSingleWallet: Boolean) = getNetworkCoinStatusUseCase(
private fun getCoinCurrencyStatusUpdates(isSingleWalletWithToken: Boolean) = getNetworkCoinStatusUseCase(
userWalletId = userWalletId,
networkId = cryptoCurrency.network.id,
derivationPath = cryptoCurrency.network.derivationPath,
isSingleWalletWithTokens = isSingleWallet,
isSingleWalletWithTokens = isSingleWalletWithToken,
).conflate().distinctUntilChanged()
private fun getCurrencyStatusUpdates(isSingleWallet: Boolean) = getCurrencyStatusUpdatesUseCase(
userWalletId = userWalletId,
currencyId = cryptoCurrency.id,
isSingleWalletWithTokens = isSingleWallet,
).conflate().distinctUntilChanged()
private fun getCurrencyStatusUpdates(
isSingleWalletWithToken: Boolean,
isMultiCurrency: Boolean,
): Flow<Either<CurrencyStatusError, CryptoCurrencyStatus>> {
return if (isMultiCurrency) {
getCurrencyStatusUpdatesUseCase(
userWalletId = userWalletId,
currencyId = cryptoCurrency.id,
isSingleWalletWithTokens = isSingleWalletWithToken,
).conflate().distinctUntilChanged()
} else {
getPrimaryCurrencyStatusUpdatesUseCase(userWalletId = userWalletId)
}
}
private suspend fun getFeeCurrencyStatusSync(
cryptoCurrencyStatus: CryptoCurrencyStatus,
isMultiCurrency: Boolean,
): CryptoCurrencyStatus {
return if (isMultiCurrency) {
getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
).getOrNull() ?: error("Fee currency is unreachable")
} else {
cryptoCurrencyStatus
}
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
@ -286,19 +334,31 @@ internal class SendViewModel @Inject constructor(
)
}
private fun onDataLoaded(currencyStatus: CryptoCurrencyStatus, coinCurrencyStatus: CryptoCurrencyStatus) {
private fun onDataLoaded(
currencyStatus: CryptoCurrencyStatus,
coinCurrencyStatus: CryptoCurrencyStatus,
feeCurrencyStatus: CryptoCurrencyStatus,
) {
cryptoCurrencyStatus = currencyStatus
coinCryptoCurrencyStatus = coinCurrencyStatus
feeCryptoCurrencyStatus = feeCurrencyStatus
if (transactionId != null && amount != null && destinationAddress != null) {
uiState = stateFactory.getReadyState(amount, destinationAddress)
stateRouter.showFee()
} else {
getWalletsAndRecent()
uiState = stateFactory.getReadyState()
stateRouter.showRecipient()
when {
uiState.sendState.isSuccess -> {
stateRouter.showSend()
}
transactionId != null && amount != null && destinationAddress != null -> {
uiState = stateFactory.getReadyState(amount, destinationAddress, memo)
stateRouter.showFee()
updateNotifications()
}
else -> {
getWalletsAndRecent()
uiState = stateFactory.getReadyState()
stateRouter.showRecipient()
updateNotifications()
}
}
updateNotifications()
}
private fun getWalletsAndRecent() {
@ -406,7 +466,7 @@ internal class SendViewModel @Inject constructor(
)
return
} else {
uiState = stateFactory.onSubtractSelect(false)
uiState = stateFactory.onSubtractSelect(false, isAmountSubtractAvailable)
analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount(false))
}
if (checkIfFeeTooLow(uiState)) {
@ -454,13 +514,14 @@ internal class SendViewModel @Inject constructor(
// region recipient state clicks
fun onRecipientAddressScanned(address: String) {
viewModelScope.launch(dispatchers.main) {
parseSharedAddressUseCase(address, cryptoCurrency.network).fold(
parseQrCodeUseCase(address, cryptoCurrency).fold(
ifRight = { parsedCode ->
onRecipientAddressValueChange(parsedCode.address, EnterAddressSource.QRCode)
parsedCode.amount?.let { onAmountValueChange(it.toPlainString()) }
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
},
ifLeft = {
onRecipientAddressValueChange(address, EnterAddressSource.QRCode)
Timber.w(it)
},
)
@ -475,6 +536,7 @@ internal class SendViewModel @Inject constructor(
val isValidAddress = validateAddress(value)
uiState = stateFactory.getOnRecipientAddressValidState(value, isValidAddress)
type?.let { analyticsEventHandler.send(SendAnalyticEvents.AddressEntered(it, isValidAddress)) }
autoNextFromRecipient(type, isValidAddress)
}
}.saveIn(addressValidationJobHolder)
}
@ -487,7 +549,7 @@ internal class SendViewModel @Inject constructor(
val isValidAddress = validateAddress(uiState.recipientState?.addressTextField?.value.orEmpty())
uiState = stateFactory.getOnRecipientMemoValidState(value, isValidAddress)
}
}.saveIn(addressValidationJobHolder)
}.saveIn(memoValidationJobHolder)
}
private suspend fun validateAddress(value: String): Boolean {
@ -519,6 +581,12 @@ internal class SendViewModel @Inject constructor(
),
)
}
private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean) {
val isRecent = type == EnterAddressSource.RecentAddress
val isAddressOnly = uiState.recipientState?.memoTextField == null
if (isRecent && isAddressOnly && isValidAddress) onNextClick()
}
// endregion
// region fee
@ -538,7 +606,7 @@ internal class SendViewModel @Inject constructor(
}
override fun onSubtractSelect() {
uiState = stateFactory.onSubtractSelect(true)
uiState = stateFactory.onSubtractSelect(true, isAmountSubtractAvailable)
stateRouter.showSend()
analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount(true))
}
@ -645,16 +713,10 @@ internal class SendViewModel @Inject constructor(
val fee = feeState.fee ?: return
val memo = uiState.recipientState?.memoTextField?.value
val amountValue = uiState.amountState?.amountTextField?.cryptoAmount?.value ?: return
val amountToSend = if (uiState.sendState.isSubtract && isAmountSubtractAvailable) {
val feeValue = fee.amount.value ?: return
amountValue.minus(feeValue)
} else {
amountValue
}
viewModelScope.launch(dispatchers.main) {
createTransactionUseCase(
amount = amountToSend.convertToAmount(cryptoCurrency),
amount = amountValue.convertToAmount(cryptoCurrency),
fee = fee,
memo = memo,
destination = recipient,

View file

@ -40,7 +40,7 @@ sealed class DataError {
data class InvalidPayoutAddressError(override val code: Int = 992) : DataError()
object UnknownError : DataError() {
data object UnknownError : DataError() {
override val code: Int = -1
}
}

View file

@ -7,4 +7,6 @@ sealed class Warning {
data class ExistentialDepositWarning(val existentialDeposit: BigDecimal) : Warning()
data class MinAmountWarning(val dustValue: BigDecimal) : Warning()
data class ReduceAmountWarning(val tezosFeeThreshold: BigDecimal) : Warning()
}

View file

@ -79,8 +79,6 @@ interface SwapInteractor {
*/
fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount
fun isAvailableToSwap(networkId: String): Boolean
fun getSelectedWallet(): UserWallet?
suspend fun selectInitialCurrencyToSwap(

View file

@ -197,7 +197,6 @@ internal class SwapInteractorImpl @Inject constructor(
return repository.getPairs(initialCurrency, currenciesList)
}
@Deprecated("used in old swap mechanism")
override suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): TxState {
val derivationPath = permissionOptions.fromToken.network.derivationPath.value
val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) {
@ -241,7 +240,6 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
@Deprecated("used in old swap mechanism")
override suspend fun findBestQuote(
fromToken: CryptoCurrencyStatus,
toToken: CryptoCurrencyStatus,
@ -376,6 +374,7 @@ internal class SwapInteractorImpl @Inject constructor(
val warnings = mutableListOf<Warning>()
manageExistentialDepositWarning(warnings, userWalletId, amount, fromToken)
manageDustWarning(warnings, feeState, userWalletId, fromTokenStatus, amount)
manageReduceAmountWarning(warnings, fromTokenStatus, amount)
return warnings
}
@ -424,6 +423,17 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private fun manageReduceAmountWarning(
warnings: MutableList<Warning>,
fromTokenStatus: CryptoCurrencyStatus,
amount: SwapAmount,
) {
val isTezos = fromTokenStatus.currency.network.id.value == Blockchain.Tezos.id
if (isTezos && amount.value == fromTokenStatus.value.amount) {
warnings.add(Warning.ReduceAmountWarning(TEZOS_FEE_THRESHOLD))
}
}
override suspend fun onSwap(
swapProvider: SwapProvider,
swapData: SwapDataModel?,
@ -729,16 +739,10 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
@Deprecated("used in old swap mechanism")
override fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount {
return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals)
}
@Deprecated("used in old swap mechanism")
override fun isAvailableToSwap(networkId: String): Boolean {
return ONE_INCH_SUPPORTED_NETWORKS.contains(networkId)
}
override suspend fun selectInitialCurrencyToSwap(
initialCryptoCurrency: CryptoCurrency,
state: TokensDataStateExpress,
@ -1584,16 +1588,6 @@ internal class SwapInteractorImpl @Inject constructor(
private const val INCREASE_GAS_LIMIT_BY = 112 // 12%
private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5%
private const val INFINITY_SYMBOL = ""
private val ONE_INCH_SUPPORTED_NETWORKS = listOf(
"ethereum",
"binance-smart-chain",
"polygon-pos",
"optimistic-ethereum",
"arbitrum-one",
"xdai",
"avalanche",
"fantom",
)
private val TEZOS_FEE_THRESHOLD = BigDecimal("0.01")
}
}

View file

@ -67,4 +67,5 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -20,6 +20,7 @@ data class SwapStateHolder(
val alert: SwapWarning.GenericWarning? = null,
val changeCardsButtonState: ChangeCardsButtonState = ChangeCardsButtonState.ENABLED,
val providerState: ProviderState,
val reduceAmountIgnore: Boolean, // ignore warning about reducing XTZ amount by 0.01
val fee: FeeItemState = FeeItemState.Empty,
val permissionState: SwapPermissionState = SwapPermissionState.Empty,
@ -108,7 +109,6 @@ sealed interface SwapWarning {
val title: TextReference? = null,
val message: TextReference? = null,
val type: GenericWarningType = GenericWarningType.OTHER,
val shouldWrapMessage: Boolean = false,
val onClick: () -> Unit,
) : SwapWarning
data class GeneralError(val notificationConfig: NotificationConfig) : SwapWarning
@ -116,6 +116,8 @@ sealed interface SwapWarning {
data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning
data class GeneralInformational(val notificationConfig: NotificationConfig) : SwapWarning
data class TransactionInProgressWarning(val title: TextReference, val description: TextReference) : SwapWarning
data class NeedReserveToCreateAccount(val notificationConfig: NotificationConfig) : SwapWarning
data class ReduceAmount(val notificationConfig: NotificationConfig) : SwapWarning
}
enum class GenericWarningType {

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.models
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.TxFee
data class UiActions(
@ -13,6 +14,8 @@ data class UiActions(
val onChangeCardsClicked: () -> Unit,
val onBackClicked: () -> Unit,
val onMaxAmountSelected: () -> Unit,
val onReduceAmount: (SwapAmount) -> Unit,
val onReduceAmountIgnoreClick: () -> Unit,
val openPermissionBottomSheet: () -> Unit,
val onChangeApproveType: (ApproveType) -> Unit,
// region new actions

View file

@ -88,6 +88,7 @@ internal class StateBuilder(
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
providerState = ProviderState.Empty(),
shouldShowMaxAmount = false,
reduceAmountIgnore = false,
priceImpact = PriceImpact.Empty(),
)
}
@ -213,7 +214,11 @@ internal class StateBuilder(
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = getWarningsForSuccessState(quoteModel, fromToken)
val warnings = getWarningsForSuccessState(
quoteModel = quoteModel,
fromToken = fromToken,
ignoreAmountReduce = uiStateHolder.reduceAmountIgnore,
)
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus
@ -311,41 +316,28 @@ internal class StateBuilder(
private fun getWarningsForSuccessState(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
ignoreAmountReduce: Boolean,
): List<SwapWarning> {
val warnings = mutableListOf<SwapWarning>()
addDomainWarnings(quoteModel, warnings)
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
warnings.add(
SwapWarning.PermissionNeeded(
createPermissionNotificationConfig(fromToken.symbol),
),
)
}
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included ->
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
),
)
else -> Unit
}
addUnableCoverFeeWarning(quoteModel, fromToken, warnings)
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
if (!quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
) {
warnings.add(SwapWarning.InsufficientFunds)
}
maybeAddDomainWarnings(quoteModel, warnings, ignoreAmountReduce)
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
maybeAddInsufficientFundsWarning(quoteModel, warnings)
maybeAddTransactionInProgressWarning(quoteModel, warnings)
return warnings
}
private fun maybeAddTransactionInProgressWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
if (quoteModel.permissionState is PermissionDataState.PermissionLoading) {
warnings.add(
SwapWarning.TransactionInProgressWarning(
title = resourceReference(R.string.swapping_pending_transaction_title),
description = resourceReference(R.string.swapping_pending_transaction_subtitle),
title = resourceReference(R.string.warning_express_approval_in_progress_title),
description = resourceReference(R.string.warning_express_approval_in_progress_message),
),
)
} else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) {
@ -361,10 +353,13 @@ internal class StateBuilder(
),
)
}
return warnings
}
private fun addDomainWarnings(quoteModel: SwapState.QuotesLoadedState, warnings: MutableList<SwapWarning>) {
private fun maybeAddDomainWarnings(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
ignoreAmountReduce: Boolean,
) {
quoteModel.warnings.forEach {
when (it) {
is Warning.ExistentialDepositWarning -> {
@ -390,7 +385,7 @@ internal class StateBuilder(
NotificationConfig(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(
R.string.send_notification_invalid_minimum_amount_text,
R.string.warning_express_dust_message,
wrappedList(
it.dustValue.toPlainString(),
it.dustValue.toPlainString(),
@ -401,11 +396,84 @@ internal class StateBuilder(
),
)
}
is Warning.ReduceAmountWarning -> {
if (!ignoreAmountReduce) {
warnings.add(
SwapWarning.ReduceAmount(
notificationConfig = createReduceAmountNotificationConfig(
amount = it.tezosFeeThreshold.toPlainString(),
onConfirmClick = {
val fromAmount = quoteModel.fromTokenInfo.tokenAmount
val patchedAmount = fromAmount.copy(
value = fromAmount.value - it.tezosFeeThreshold,
)
actions.onReduceAmount(patchedAmount)
},
onDismissClick = actions.onReduceAmountIgnoreClick,
),
),
)
}
}
}
}
}
private fun addUnableCoverFeeWarning(
private fun maybeAddNeedReserveToCreateAccountWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
if (amount < amountToCreateAccount) {
warnings.add(
SwapWarning.NeedReserveToCreateAccount(
notificationConfig = createActivateAccountNotificationConfig(
status.amountToCreateAccount,
quoteModel.toTokenInfo.cryptoCurrencyStatus.currency.name,
),
),
)
}
}
}
private fun maybeAddPermissionNeededWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
fromToken: CryptoCurrency,
) {
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
warnings.add(
SwapWarning.PermissionNeeded(
createPermissionNotificationConfig(fromToken.symbol),
),
)
}
}
private fun maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included ->
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
),
)
else -> Unit
}
}
private fun maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
warnings: MutableList<SwapWarning>,
@ -428,7 +496,29 @@ internal class StateBuilder(
}
}
private fun maybeAddInsufficientFundsWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
) {
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
if (!quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
) {
warnings.add(SwapWarning.InsufficientFunds)
}
}
private fun getSwapButtonEnabled(quoteModel: SwapState.QuotesLoadedState): Boolean {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
val amount = quoteModel.toTokenInfo.tokenAmount.value
val amountToCreateAccount = status.amountToCreateAccount
if (amount < amountToCreateAccount) {
return false
}
}
val preparedSwapConfigState = quoteModel.preparedSwapConfigState
// check has has outgoing transaction
if (preparedSwapConfigState.hasOutgoingTransaction) return false
@ -587,7 +677,7 @@ internal class StateBuilder(
subtitle = if (dataError is DataError.UnknownError) {
resourceReference(R.string.common_unknown_error)
} else {
resourceReference(R.string.generic_error_code, wrappedList(dataError.code.toString()))
resourceReference(R.string.express_error_code, wrappedList(dataError.code.toString()))
},
iconResId = R.drawable.img_attention_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
@ -774,7 +864,7 @@ internal class StateBuilder(
SwapWarning.GeneralWarning(
notificationConfig = NotificationConfig(
title = TextReference.Res(R.string.warning_express_refresh_required_title),
subtitle = TextReference.Res(R.string.generic_error_code, wrappedList(code)),
subtitle = TextReference.Res(R.string.express_error_code, wrappedList(code)),
iconResId = R.drawable.ic_alert_triangle_20,
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = TextReference.Res(R.string.warning_button_refresh),
@ -823,8 +913,8 @@ internal class StateBuilder(
warnings.add(
0,
SwapWarning.TransactionInProgressWarning(
title = resourceReference(R.string.swapping_pending_transaction_title),
description = resourceReference(R.string.swapping_pending_transaction_subtitle),
title = resourceReference(R.string.warning_express_approval_in_progress_title),
description = resourceReference(R.string.warning_express_approval_in_progress_message),
),
)
return uiState.copy(
@ -928,17 +1018,11 @@ internal class StateBuilder(
fun clearAlert(uiState: SwapStateHolder): SwapStateHolder = uiState.copy(alert = null)
fun addWarning(
uiState: SwapStateHolder,
message: TextReference?,
shouldWrapMessage: Boolean = false,
onClick: () -> Unit,
): SwapStateHolder {
fun addWarning(uiState: SwapStateHolder, message: TextReference?, onClick: () -> Unit): SwapStateHolder {
val renewWarnings = uiState.warnings.filterNot { it is SwapWarning.GenericWarning }.toMutableList()
renewWarnings.add(
SwapWarning.GenericWarning(
message = message,
shouldWrapMessage = shouldWrapMessage,
onClick = onClick,
),
)
@ -1220,6 +1304,35 @@ internal class StateBuilder(
)
}
private fun createActivateAccountNotificationConfig(amount: BigDecimal, token: String): NotificationConfig {
return NotificationConfig(
title = resourceReference(
id = R.string.send_notification_invalid_reserve_amount_title,
formatArgs = wrappedList("$amount $token"),
),
subtitle = resourceReference(R.string.send_notification_invalid_reserve_amount_text),
iconResId = R.drawable.img_attention_20,
)
}
private fun createReduceAmountNotificationConfig(
amount: String,
onConfirmClick: () -> Unit,
onDismissClick: () -> Unit,
): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
iconResId = R.drawable.img_attention_20,
buttonsState = NotificationConfig.ButtonsState.PairButtonsConfig(
primaryText = resourceReference(R.string.xtz_withdrawal_message_reduce, wrappedList(amount)),
onPrimaryClick = onConfirmClick,
secondaryText = resourceReference(R.string.xtz_withdrawal_message_ignore),
onSecondaryClick = onDismissClick,
),
)
}
private fun createUnableToCoverFeeNotificationConfig(
fromToken: CryptoCurrency,
feeCurrency: CryptoCurrency?,

View file

@ -337,14 +337,9 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
)
}
is SwapWarning.GenericWarning -> {
val message = warning.message?.let {
if (warning.shouldWrapMessage) {
String.format(stringResource(id = R.string.swapping_error_wrapper), it.resolveReference())
} else {
it.resolveReference()
}
} ?: stringResource(id = R.string.common_unknown_error)
RefreshableWaringCard(
val message = warning.message?.resolveReference()
?: stringResource(id = R.string.common_unknown_error)
RefreshableWarningCard(
title = stringResource(id = R.string.common_warning),
description = message,
onClick = warning.onClick,
@ -377,6 +372,16 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
iconTint = TangemTheme.colors.icon.accent,
)
}
is SwapWarning.NeedReserveToCreateAccount -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.ReduceAmount -> {
Notification(
config = warning.notificationConfig,
)
}
is SwapWarning.TransactionInProgressWarning -> {
CardWithIcon(
title = warning.title.resolveReference(),
@ -499,6 +504,7 @@ private val state = SwapStateHolder(
providerState = ProviderState.Loading(),
priceImpact = PriceImpact.Empty(),
shouldShowMaxAmount = true,
reduceAmountIgnore = false,
tosState = TosState(
tosLink = LegalState(
title = stringReference("Terms of Use"),

View file

@ -27,6 +27,7 @@ import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.ExpressException
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
import com.tangem.feature.swap.domain.models.ui.*
@ -812,6 +813,10 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun onReduceAmountClicked(newAmount: SwapAmount) {
onAmountChanged(newAmount.formatToUIRepresentation())
}
@Suppress("UnusedPrivateMember")
private fun onAmountSelected(selected: Boolean) {
if (selected) {
@ -874,7 +879,14 @@ internal class SwapViewModel @Inject constructor(
}
onSearchEntered("")
},
onMaxAmountSelected = { onMaxAmountClicked() },
onMaxAmountSelected = ::onMaxAmountClicked,
onReduceAmount = ::onReduceAmountClicked,
onReduceAmountIgnoreClick = {
uiState = uiState.copy(
reduceAmountIgnore = true,
warnings = uiState.warnings.filter { it !is SwapWarning.ReduceAmount },
)
},
openPermissionBottomSheet = {
singleTaskScheduler.cancelTask()
analyticsEventHandler.send(SwapEvents.ButtonGivePermissionClicked)
@ -1153,11 +1165,11 @@ internal class SwapViewModel @Inject constructor(
)
}
companion object {
private const val loggingTag = "SwapViewModel"
private const val INITIAL_AMOUNT = ""
private const val UPDATE_DELAY = 10000L
private const val DEBOUNCE_AMOUNT_DELAY = 1000L
private const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
private companion object {
const val loggingTag = "SwapViewModel"
const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L
const val DEBOUNCE_AMOUNT_DELAY = 1000L
const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
}
}

View file

@ -165,7 +165,7 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
object TopUpWithoutReserve : Informational(
title = resourceReference(id = R.string.warning_no_account_title),
subtitle = resourceReference(id = R.string.no_account_bnb),
subtitle = resourceReference(id = R.string.no_account_send_to_create),
)
class HasPendingTransactions(val coinSymbol: String) : Informational(

View file

@ -19,6 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.inputrow.InputRowBestRate
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
@Composable
@ -67,7 +68,7 @@ internal fun ExchangeProvider(
)
Text(
modifier = Modifier.align(Alignment.CenterVertically),
text = "ID: $providerTxId",
text = stringResource(R.string.express_transaction_id, wrappedList(providerTxId)),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)

View file

@ -174,9 +174,10 @@ internal class TokenDetailsViewModel @Inject constructor(
status = cryptoCurrencyStatus ?: return,
transactionInfo = data.let {
TransactionInfo(
amount = it.baseCurrencyAmount,
transactionId = it.transactionId,
destinationAddress = it.depositWalletAddress,
amount = it.baseCurrencyAmount,
tag = it.depositWalletAddressTag,
)
},
)

View file

@ -84,6 +84,7 @@ internal class WalletDeepLinksHandler @Inject constructor(
amount = it.baseCurrencyAmount,
destinationAddress = it.depositWalletAddress,
transactionId = it.transactionId,
tag = it.depositWalletAddressTag,
)
}

View file

@ -23,7 +23,7 @@ sealed class WalletScreenAnalyticsEvent {
override val oneTimeEventId: String = id + userWalletId.stringValue
}
object WalletOpened : Basic(event = "Wallet Opened")
data object WalletOpened : Basic(event = "Wallet Opened")
class CardWasScanned(source: AnalyticsParam.ScannedFrom) : Basic(
event = "Card Was Scanned",
@ -53,8 +53,8 @@ sealed class WalletScreenAnalyticsEvent {
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Main Screen", event = event, params = params) {
object ScreenOpened : MainScreen(event = "Screen opened")
object WalletSwipe : MainScreen(event = "Wallet Swipe")
data object ScreenOpened : MainScreen(event = "Screen opened")
data object WalletSwipe : MainScreen(event = "Wallet Swipe")
class EnableBiometrics(state: AnalyticsParam.OnOffState) : MainScreen(
event = "Enable Biometric",
@ -66,37 +66,39 @@ sealed class WalletScreenAnalyticsEvent {
params = mapOf("Result" to result.value),
)
object NoticeBackupYourWalletTapped : MainScreen(event = "Notice - Backup Your Wallet Tapped")
object NoticeScanYourCardTapped : MainScreen(event = "Notice - Scan Your Card Tapped")
object NoticeWalletLocked : MainScreen(event = "Notice - Wallet Locked")
object WalletUnlockTapped : MainScreen(event = "Notice - Wallet Unlock Tapped")
data object NoticeBackupYourWalletTapped : MainScreen(event = "Notice - Backup Your Wallet Tapped")
data object NoticeScanYourCardTapped : MainScreen(event = "Notice - Scan Your Card Tapped")
data object NoticeWalletLocked : MainScreen(event = "Notice - Wallet Locked")
data object WalletUnlockTapped : MainScreen(event = "Notice - Wallet Unlock Tapped")
object NetworksUnreachable : MainScreen(event = "Notice - Networks Unreachable")
data object NetworksUnreachable : MainScreen(event = "Notice - Networks Unreachable")
object MissingAddresses : MainScreen(event = "Notice - Missing Addresses")
data object MissingAddresses : MainScreen(event = "Notice - Missing Addresses")
object CardSignedTransactions : MainScreen(event = "Notice - Card Signed Transactions")
data object CardSignedTransactions : MainScreen(event = "Notice - Card Signed Transactions")
object HowDoYouLikeTangem : MainScreen(event = "Notice - How Do You Like Tangem")
data object HowDoYouLikeTangem : MainScreen(event = "Notice - How Do You Like Tangem")
object ProductSampleCard : MainScreen(event = "Notice - Product Sample Card")
data object ProductSampleCard : MainScreen(event = "Notice - Product Sample Card")
object TestnetCard : MainScreen(event = "Notice - Testnet Card")
data object TestnetCard : MainScreen(event = "Notice - Testnet Card")
object DemoCard : MainScreen(event = "Notice - Demo Card")
data object DemoCard : MainScreen(event = "Notice - Demo Card")
object DevelopmentCard : MainScreen(event = "Notice - Development Card")
data object DevelopmentCard : MainScreen(event = "Notice - Development Card")
object WalletUnlock : MainScreen(event = "Notice - Wallet Unlock")
data object WalletUnlock : MainScreen(event = "Notice - Wallet Unlock")
object BackupYourWallet : MainScreen(event = "Notice - Backup Your Wallet")
data object BackupYourWallet : MainScreen(event = "Notice - Backup Your Wallet")
object UnlockAllWithBiometrics : MainScreen(event = "Button - Unlock All With Biometrics")
data object BackupError : MainScreen(event = "Notice - Backup Error")
object UnlockWithCardScan : MainScreen(event = "Button - Unlock With Card Scan")
data object UnlockAllWithBiometrics : MainScreen(event = "Button - Unlock All With Biometrics")
object EditWalletTapped : MainScreen(event = "Button - Edit Wallet Tapped")
data object UnlockWithCardScan : MainScreen(event = "Button - Unlock With Card Scan")
object DeleteWalletTapped : MainScreen(event = "Button - Delete Wallet Tapped")
data object EditWalletTapped : MainScreen(event = "Button - Edit Wallet Tapped")
data object DeleteWalletTapped : MainScreen(event = "Button - Delete Wallet Tapped")
}
}

View file

@ -43,6 +43,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.Informational.DemoCard -> MainScreen.DemoCard
is WalletNotification.Informational.MissingAddresses -> MainScreen.MissingAddresses
is WalletNotification.RateApp -> MainScreen.HowDoYouLikeTangem
is WalletNotification.Critical.BackupError -> MainScreen.BackupError
is WalletNotification.UnlockWallets -> null // See [SelectedWalletAnalyticsSender]
is WalletNotification.Informational.NoAccount,
is WalletNotification.Warning.LowSignatures,

View file

@ -0,0 +1,42 @@
package com.tangem.feature.wallet.presentation.wallet.domain
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.common.configs.CardConfig
import com.tangem.domain.models.scan.CardDTO
import javax.inject.Inject
class BackupValidator @Inject constructor() {
fun isValid(cardDTO: CardDTO): Boolean {
return validateBackupStatus(cardDTO) && validateCurves(cardDTO)
}
private fun validateCurves(cardDTO: CardDTO): Boolean {
val config = CardConfig.createConfig(cardDTO)
// / Since the curve `bls12381_G2_AUG` was added later into first generation of wallets,
// we cannot determine whether this curve is missing due to an error or because the user
// did not want to recreate the wallet.
val expectedCurves = config.mandatoryCurves
.filterNot { it == EllipticCurve.Bls12381G2Aug }
val curves = cardDTO.wallets.map { it.curve }
for (expectedCurve in expectedCurves) {
val cardCurvesCount = curves.count { it == expectedCurve }
// missing curve
if (cardCurvesCount == 0) {
return false
}
// duplicated curve
if (cardCurvesCount > 1) {
return false
}
}
return true
}
private fun validateBackupStatus(cardDTO: CardDTO): Boolean {
val backupStatus = cardDTO.backupStatus
return backupStatus != null && backupStatus !is CardDTO.BackupStatus.CardLinked
}
}

View file

@ -37,6 +37,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase,
private val promoRepository: PromoRepository,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator,
) {
private var readyForRateAppNotification = false
@ -57,7 +58,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
buildList {
addSwapPromoNotification(shouldShowPromo, promoBanner, clickIntents)
addCriticalNotifications(cardTypesResolver)
addCriticalNotifications(userWallet)
addInformationalNotifications(cardTypesResolver, maybeTokenList, clickIntents)
@ -85,7 +86,13 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
)
}
private fun MutableList<WalletNotification>.addCriticalNotifications(cardTypesResolver: CardTypesResolver) {
private fun MutableList<WalletNotification>.addCriticalNotifications(userWallet: UserWallet) {
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
addIf(
element = WalletNotification.Critical.BackupError,
condition = !backupValidator.isValid(userWallet.scanResponse.card) || userWallet.hasBackupError,
)
addIf(
element = WalletNotification.Critical.DevCard,
condition = !cardTypesResolver.isReleaseFirmwareType(),

View file

@ -36,7 +36,10 @@ sealed class WalletBottomSheetConfig(
),
iconResId = R.drawable.ic_locked_24,
primaryButtonConfig = ButtonConfig(
text = resourceReference(id = R.string.user_wallet_list_unlock_all),
text = resourceReference(
id = R.string.user_wallet_list_unlock_all_with,
formatArgs = wrappedList(resourceReference(R.string.common_biometrics)),
),
onClick = onUnlockClick,
),
secondaryButtonConfig = ButtonConfig(

View file

@ -36,6 +36,11 @@ sealed class WalletNotification(val config: NotificationConfig) {
title = resourceReference(id = R.string.warning_failed_to_verify_card_title),
subtitle = resourceReference(id = R.string.warning_failed_to_verify_card_message),
)
data object BackupError : Critical(
title = resourceReference(R.string.warning_backup_errors_title),
subtitle = resourceReference(R.string.warning_backup_errors_message),
)
}
sealed class Warning(