Updated on 2026-08-14

This commit is contained in:
Tangem 2024-05-17 14:05:26 +05:00
commit 63849d3c02
155 changed files with 2317 additions and 607 deletions

View file

@ -20,10 +20,9 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.UiDependencies
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.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningContent
@ -40,10 +39,7 @@ import kotlin.properties.Delegates
internal class QrScanningFragment : ComposeFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
@Inject
override lateinit var hapticManager: HapticManager
override lateinit var uiDependencies: UiDependencies
@Inject
lateinit var router: QrScanningRouter

View file

@ -5,11 +5,10 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.haptic.HapticManager
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.referral.router.ReferralRouter
import com.tangem.feature.referral.ui.ReferralScreen
import com.tangem.feature.referral.viewmodels.ReferralViewModel
@ -21,10 +20,7 @@ import javax.inject.Inject
class ReferralFragment : ComposeFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
@Inject
override lateinit var hapticManager: HapticManager
override lateinit var uiDependencies: UiDependencies
private val viewModel by viewModels<ReferralViewModel>()

View file

@ -7,4 +7,7 @@ interface SendFeatureToggles {
/** Availability of redesigned send screen */
val isRedesignedSendEnabled: Boolean
/** Updates remote toggle */
suspend fun fetchNewSendEnabled()
}

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.core.navigation)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.datasource)
/** Common */
implementation(projects.common)

View file

@ -1,8 +1,10 @@
package com.tangem.features.send.impl.di
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.features.send.impl.featuretoggles.DefaultSendFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -18,7 +20,15 @@ internal object SendFeatureTogglesModule {
@Provides
@Singleton
fun provideSendFeatureToggles(featureTogglesManager: FeatureTogglesManager): SendFeatureToggles {
return DefaultSendFeatureToggles(featureTogglesManager = featureTogglesManager)
fun provideSendFeatureToggles(
featureTogglesManager: FeatureTogglesManager,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): SendFeatureToggles {
return DefaultSendFeatureToggles(
featureTogglesManager = featureTogglesManager,
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
)
}
}

View file

@ -1,16 +1,41 @@
package com.tangem.features.send.impl.featuretoggles
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import timber.log.Timber
/**
* Default implementation of Send feature toggles
*
* @property featureTogglesManager manager for getting information about the availability of feature toggles
* @property tangemTechApi api to get remote feature toggle for send
* @property dispatchers coroutine dispatchers
*/
internal class DefaultSendFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) : SendFeatureToggles {
private val remoteSendEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true)
override val isRedesignedSendEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_SEND_SCREEN_ENABLED")
get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_SEND_SCREEN_ENABLED") &&
remoteSendEnabled.value
override suspend fun fetchNewSendEnabled() {
runCatching(dispatchers.io) {
tangemTechApi.getFeatures().getOrThrow()
}.onSuccess { response ->
remoteSendEnabled.update { response.isNewSendEnabled }
}.onFailure {
Timber.e(it.localizedMessage, "Unable to fetch new send toggle")
}
}
}

View file

@ -6,11 +6,10 @@ import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
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
@ -27,10 +26,7 @@ import javax.inject.Inject
internal class SendFragment : ComposeFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
@Inject
override lateinit var hapticManager: HapticManager
override lateinit var uiDependencies: UiDependencies
@Inject
lateinit var router: SendRouter

View file

@ -1,7 +1,9 @@
package com.tangem.features.send.impl.presentation.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
@ -66,9 +68,9 @@ internal sealed class SendAnalyticEvents(
data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened")
/** Selected fee (send after next screen opened) */
data class SelectedFee(val feeType: SelectedFeeType) : SendAnalyticEvents(
data class SelectedFee(val feeType: AnalyticsParam.FeeType) : SendAnalyticEvents(
event = "Fee Selected",
params = mapOf("Fee Type" to feeType.name),
params = mapOf("Fee Type" to feeType.value),
)
/** Custom fee selected */
@ -97,7 +99,16 @@ internal sealed class SendAnalyticEvents(
// region Transaction Result
/** Transaction send screen opened */
data object TransactionScreenOpened : SendAnalyticEvents(event = "Transaction Sent Screen Opened")
data class TransactionScreenOpened(
val token: String,
val feeType: AnalyticsParam.FeeType,
) : SendAnalyticEvents(
event = "Transaction Sent Screen Opened",
params = mapOf(
TOKEN to token,
FEE_TYPE to feeType.value,
),
)
/** Share button clicked */
data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share")
@ -145,12 +156,4 @@ internal enum class EnterAddressSource {
internal enum class SelectedCurrencyType(val value: String) {
Token("Token"),
AppCurrency("App Currency"),
}
internal enum class SelectedFeeType {
Min,
Max,
Fixed,
Normal,
Custom,
}

View file

@ -2,8 +2,10 @@ package com.tangem.features.send.impl.presentation.analytics.utils
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.send.impl.presentation.analytics.SelectedCurrencyType
import com.tangem.features.send.impl.presentation.analytics.SelectedFeeType
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.analytics.SendScreenSource
import com.tangem.features.send.impl.presentation.state.SendUiState
@ -11,11 +13,13 @@ import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.utils.Provider
internal class SendScreenAnalyticSender(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyProvider: Provider<CryptoCurrency>,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
fun send(prevScreen: SendUiStateType, state: SendUiState) {
@ -73,16 +77,57 @@ internal class SendScreenAnalyticSender(
)
}
fun sendTransaction() {
val state = currentStateProvider()
val isEditState = stateRouterProvider().isEditState
val cryptoCurrency = cryptoCurrencyProvider()
val feeState = state.getFeeState(isEditState) ?: return
val recipientState = state.getRecipientState(isEditState) ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val feeType = getSendTransactionFeeType(feeSelectorState)
analyticsEventHandler.send(
SendAnalyticEvents.TransactionScreenOpened(
token = cryptoCurrency.symbol,
feeType = feeType,
),
)
analyticsEventHandler.send(
Basic.TransactionSent(
sentFrom = AnalyticsParam.TxSentFrom.Send(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
feeType = feeType,
),
memoType = getSendTransactionMemoType(recipientState.memoTextField),
),
)
}
private fun sendSelectedFeeAnalytics(feeSelectorState: FeeSelectorState.Content) {
val type = when (feeSelectorState.fees) {
is TransactionFee.Single -> SelectedFeeType.Fixed
is TransactionFee.Choosable -> when (feeSelectorState.selectedFee) {
FeeType.Slow -> SelectedFeeType.Min
FeeType.Market -> SelectedFeeType.Normal
FeeType.Fast -> SelectedFeeType.Max
FeeType.Custom -> SelectedFeeType.Custom
}
}
val type = getSendTransactionFeeType(feeSelectorState)
analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(type))
}
private fun getSendTransactionFeeType(feeSelectorState: FeeSelectorState.Content): AnalyticsParam.FeeType =
when (feeSelectorState.fees) {
is TransactionFee.Single -> AnalyticsParam.FeeType.Fixed
is TransactionFee.Choosable -> when (feeSelectorState.selectedFee) {
FeeType.Slow -> AnalyticsParam.FeeType.Min
FeeType.Market -> AnalyticsParam.FeeType.Normal
FeeType.Fast -> AnalyticsParam.FeeType.Max
FeeType.Custom -> AnalyticsParam.FeeType.Custom
}
}
private fun getSendTransactionMemoType(
recipientMemo: SendTextField.RecipientMemo?,
): Basic.TransactionSent.MemoType {
val memo = recipientMemo?.value
return when {
memo?.isBlank() == true -> Basic.TransactionSent.MemoType.Empty
memo?.isNotBlank() == true -> Basic.TransactionSent.MemoType.Full
else -> Basic.TransactionSent.MemoType.Null
}
}
}

View file

@ -1,15 +1,18 @@
package com.tangem.features.send.impl.presentation.domain
import androidx.compose.runtime.Immutable
import com.tangem.domain.wallets.models.UserWalletId
/**
* Available wallet to send
*
* @property name wallet name
* @property userWalletId wallet id
* @property address blockchain address
*/
@Immutable
data class AvailableWallet(
val name: String,
val userWalletId: UserWalletId,
val address: String,
)

View file

@ -54,7 +54,7 @@ internal sealed class SendNotification(val config: NotificationConfig) {
wrappedList(cryptoCurrency, utxoLimit, amountLimit),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_to, wrappedList(amountLimit)),
text = resourceReference(R.string.send_notification_leave_button, wrappedList(amountLimit)),
onClick = onConfirmClick,
),
)
@ -98,7 +98,7 @@ internal sealed class SendNotification(val config: NotificationConfig) {
title = resourceReference(R.string.send_notification_existential_deposit_title),
subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_existential_deposit_button, wrappedList(deposit)),
text = resourceReference(R.string.send_notification_leave_button, wrappedList(deposit)),
onClick = onConfirmClick,
),
)
@ -119,12 +119,13 @@ internal sealed class SendNotification(val config: NotificationConfig) {
),
) {
data class HighFeeError(
val currencyName: String,
val amount: String,
val onConfirmClick: () -> Unit,
val onCloseClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(currencyName, amount)),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)),
onClick = onConfirmClick,
@ -156,9 +157,33 @@ internal sealed class SendNotification(val config: NotificationConfig) {
data class FeeCoverageNotification(val cryptoAmount: String, val fiatAmount: String) : Warning(
title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(
R.string.send_network_fee_warning_content,
R.string.common_network_fee_warning_content,
wrappedList(cryptoAmount, fiatAmount),
),
)
}
sealed interface Cardano {
data class MinAdaValueCharged(val tokenName: String, val minAdaValue: String) : Warning(
title = resourceReference(id = R.string.cardano_coin_will_be_send_with_token_title),
subtitle = resourceReference(
id = R.string.cardano_coin_will_be_send_with_token_description,
formatArgs = wrappedList(minAdaValue, tokenName),
),
)
data object InsufficientBalanceToTransferCoin : Error(
title = resourceReference(id = R.string.cardano_max_amount_has_token_title),
subtitle = resourceReference(id = R.string.cardano_max_amount_has_token_description),
)
data class InsufficientBalanceToTransferToken(val tokenName: String) : Error(
title = resourceReference(id = R.string.cardano_insufficient_balance_to_send_token_title),
subtitle = resourceReference(
id = R.string.cardano_insufficient_balance_to_send_token_description,
formatArgs = wrappedList(tokenName),
),
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.send.impl.presentation.state.confirm
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
@ -8,6 +9,7 @@ import com.tangem.blockchainsdk.utils.minimalAmount
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
@ -15,6 +17,8 @@ import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
@ -23,6 +27,7 @@ import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.utils.getFiatString
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.isTezos
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
@ -46,6 +51,7 @@ internal class SendNotificationFactory(
private val clickIntents: SendClickIntents,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
private val validateTransactionUseCase: ValidateTransactionUseCase,
) {
fun create(): Flow<ImmutableList<SendNotification>> = stateRouterProvider().currentState
@ -66,19 +72,21 @@ internal class SendNotificationFactory(
amountValue = amountValue,
feeValue = feeValue,
)
val sendingAmount = calculateSubtractedAmount(
isFeeCoverage = isFeeCoverage,
val sendingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isFeeCoverage,
cryptoCurrencyStatus = cryptoCurrencyStatusProvider(),
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = sendState.reduceAmountBy,
)
buildList {
// errors
addFeeUnreachableNotification(feeState.feeSelectorState)
addExceedBalanceNotification(feeValue, sendingAmount)
addExceedsBalanceNotification(feeState.fee)
addDustWarningNotification(feeValue, sendingAmount)
addDustWarningNotificationForSpecificBlockchains(feeValue, sendingAmount)
addTransactionLimitErrorNotification(feeValue, sendingAmount)
// warnings
addExistentialWarningNotification(feeValue, amountValue)
addFeeCoverageNotification(
@ -89,6 +97,9 @@ internal class SendNotificationFactory(
addHighFeeWarningNotification(amountValue, sendState.ignoreAmountReduce)
addTooHighNotification(feeState.feeSelectorState)
addTooLowNotification(feeState)
// blockchain specific
addCardanoNotifications(sendingAmount, feeState.fee, state)
}.toImmutableList()
}
@ -194,7 +205,7 @@ internal class SendNotificationFactory(
val spendingAmount = if (cryptoCurrency is CryptoCurrency.Token) {
feeAmount
} else {
feeAmount + receivedAmount
receivedAmount
}
val currencyDeposit = currencyChecksRepository.getExistentialDeposit(
userWalletId,
@ -260,6 +271,7 @@ internal class SendNotificationFactory(
if (!ignoreAmountReduce && isTotalBalance && isTezos) {
add(
SendNotification.Warning.HighFeeError(
currencyName = cryptoCurrencyStatus.currency.name,
amount = threshold.toPlainString(),
onConfirmClick = {
clickIntents.onAmountReduceClick(
@ -275,23 +287,35 @@ internal class SendNotificationFactory(
}
}
private suspend fun MutableList<SendNotification>.addDustWarningNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
private suspend fun MutableList<SendNotification>.addDustWarningNotificationForSpecificBlockchains(
feeValue: BigDecimal,
sendingAmount: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val dustValue = currencyChecksRepository.getDustValue(
userWalletProvider().walletId,
cryptoCurrencyStatus.currency.network,
) ?: return
val isCardano = BlockchainUtils.isCardano(cryptoCurrencyStatusProvider().currency.network.id.value)
if (checkDustLimits(feeAmount, receivedAmount, dustValue)) {
add(
SendNotification.Error.MinimumAmountError(dustValue.toPlainString()),
)
if (!isCardano) {
addDustWarningNotification(feeValue, sendingAmount)
}
}
private fun checkDustLimits(feeAmount: BigDecimal, receivedAmount: BigDecimal, dustValue: BigDecimal): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val change = when (cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> {
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
balance - (feeAmount + receivedAmount)
}
is CryptoCurrency.Token -> {
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
balance - feeAmount
}
}
val isChangeLowerThanDust = change < dustValue && change > BigDecimal.ZERO
return receivedAmount < dustValue || isChangeLowerThanDust
}
private fun MutableList<SendNotification>.addTooLowNotification(feeState: SendStates.FeeState) {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return
@ -393,13 +417,90 @@ internal class SendNotificationFactory(
return Blockchain.fromNetworkId(this.currency.network.backendId) == Blockchain.Arbitrum
}
private fun checkDustLimits(feeAmount: BigDecimal, receivedAmount: BigDecimal, dustValue: BigDecimal): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
private suspend fun MutableList<SendNotification>.addCardanoNotifications(
sendingAmount: BigDecimal,
fee: Fee?,
state: SendUiState,
) {
val sendingCurrency = cryptoCurrencyStatusProvider().currency
if (!BlockchainUtils.isCardano(sendingCurrency.network.id.value)) return
val totalAmount = feeAmount + receivedAmount
val change = balance - totalAmount
val isChangeLowerThanDust = change < dustValue && change > BigDecimal.ZERO
return receivedAmount < dustValue || isChangeLowerThanDust
validateTransactionUseCase(
amount = sendingAmount.convertToAmount(sendingCurrency),
fee = fee ?: return,
memo = state.recipientState?.memoTextField?.value,
destination = requireNotNull(state.recipientState?.addressTextField?.value),
userWalletId = userWalletProvider().walletId,
network = sendingCurrency.network,
).fold(
ifLeft = {
addCardanoTransactionValidationError(
error = it as? BlockchainSdkError.Cardano ?: return@fold,
sendingCurrency = sendingCurrency,
)
},
ifRight = {
(fee as? Fee.CardanoToken)?.let {
add(
SendNotification.Cardano.MinAdaValueCharged(
tokenName = sendingCurrency.name,
minAdaValue = it.minAdaValue.parseBigDecimal(sendingCurrency.decimals),
),
)
}
},
)
}
private suspend fun MutableList<SendNotification>.addCardanoTransactionValidationError(
error: BlockchainSdkError.Cardano,
sendingCurrency: CryptoCurrency,
) {
when (error) {
BlockchainSdkError.Cardano.InsufficientMinAdaBalanceToSendToken -> {
add(SendNotification.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name))
}
BlockchainSdkError.Cardano.InsufficientRemainingBalanceToWithdrawTokens -> {
when (sendingCurrency) {
is CryptoCurrency.Coin -> SendNotification.Cardano.InsufficientBalanceToTransferCoin
is CryptoCurrency.Token -> {
SendNotification.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name)
}
}.let(::add)
}
BlockchainSdkError.Cardano.InsufficientRemainingBalance,
BlockchainSdkError.Cardano.InsufficientSendingAdaAmount,
-> {
val dustValue = currencyChecksRepository.getDustValue(
userWalletId = userWalletProvider().walletId,
network = sendingCurrency.network,
) ?: return
add(
SendNotification.Error.MinimumAmountError(
amount = dustValue.parseBigDecimal(sendingCurrency.decimals),
),
)
}
}
}
private suspend fun MutableList<SendNotification>.addDustWarningNotification(
feeValue: BigDecimal,
sendingAmount: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val dustValue = currencyChecksRepository.getDustValue(
userWalletProvider().walletId,
cryptoCurrencyStatus.currency.network,
) ?: return
if (checkDustLimits(feeValue, sendingAmount, dustValue)) {
add(
SendNotification.Error.MinimumAmountError(
amount = dustValue.parseBigDecimal(cryptoCurrencyStatus.currency.decimals),
),
)
}
}
}

View file

@ -54,7 +54,7 @@ internal fun checkFeeCoverage(
/**
* Calculates subtracted amount
*/
internal fun calculateSubtractedAmount(
private fun calculateSubtractedAmount(
isFeeCoverage: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus,
amountValue: BigDecimal,

View file

@ -47,7 +47,7 @@ internal class BitcoinCustomFeeConverter(
keyboardType = KeyboardType.Number,
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer),
footer = resourceReference(R.string.send_bitcoin_custom_fee_footer),
label = getFiatReference(
rate = feeCurrency?.fiatRate,
value = feeValue,

View file

@ -43,7 +43,7 @@ internal class EthereumCustomFeeConverter(
keyboardType = KeyboardType.Number,
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer),
footer = resourceReference(R.string.send_evm_custom_fee_footer),
label = getFiatReference(
rate = feeCurrency?.fiatRate,
value = feeValue,

View file

@ -10,6 +10,7 @@ import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import java.math.RoundingMode
internal class SendAmountFieldMaxAmountConverter(
private val stateRouterProvider: Provider<StateRouter>,
@ -33,7 +34,7 @@ internal class SendAmountFieldMaxAmountConverter(
val isDoneActionEnabled = !decimalCryptoValue.isNullOrZero()
val cryptoValue = decimalCryptoValue?.parseBigDecimal(cryptoDecimals).orEmpty()
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals).orEmpty()
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals, roundingMode = RoundingMode.HALF_UP).orEmpty()
return state.copyWrapped(
isEditState = isEditState,
amountState = amountState.copy(

View file

@ -26,12 +26,15 @@ import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.shareText
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.utils.getFiatFormatted
@Composable
internal fun SendNavigationButtons(
@ -172,21 +175,27 @@ private fun SendingText(
}
if (feeFiat != null && sendingFiat != null) {
val sendingValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = sendingFiat,
fiatCurrencyCode = feeState.appCurrency.code,
fiatCurrencySymbol = feeState.appCurrency.symbol,
val sendingValue = getFiatFormatted(
value = sendingFiat,
currencySymbol = feeState.appCurrency.symbol,
currencyCode = feeState.appCurrency.code,
)
val feeValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeFiat,
fiatCurrencyCode = feeState.appCurrency.code,
fiatCurrencySymbol = feeState.appCurrency.symbol,
val feeValue = getFiatFormatted(
value = feeState.fee?.amount?.value,
currencySymbol = feeState.appCurrency.symbol,
currencyCode = feeState.appCurrency.code,
)
val textResource = remember(sendingValue, feeValue) {
resourceReference(
id = R.string.send_summary_transaction_description,
formatArgs = wrappedList(sendingValue, feeValue),
)
}
Text(
text = stringResource(id = R.string.send_summary_transaction_description, sendingValue, feeValue),
text = textResource.resolveAnnotatedReference(),
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing12),

View file

@ -33,7 +33,10 @@ import kotlinx.coroutines.flow.withIndex
@Composable
internal fun SendScreen(uiState: SendUiState, currentState: SendUiCurrentScreen) {
val snackbarHostState = remember { SnackbarHostState() }
BackHandler(onBack = uiState.clickIntents::onBackClick)
val onBackClick = uiState.clickIntents::onBackClick.takeIf {
uiState.sendState?.isSending != true
} ?: {}
BackHandler(onBack = onBackClick)
Column(
modifier = Modifier
.fillMaxSize()

View file

@ -15,6 +15,7 @@ internal fun LazyListScope.notifications(
notifications: ImmutableList<SendNotification>,
modifier: Modifier = Modifier,
hasPaddingAbove: Boolean = false,
isClickDisabled: Boolean = false,
) {
itemsIndexed(
items = notifications,
@ -44,6 +45,7 @@ internal fun LazyListScope.notifications(
-> null
is SendNotification.Error -> TangemTheme.colors.icon.warning
},
isEnabled = !isClickDisabled,
)
},
)

View file

@ -16,6 +16,7 @@ import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
@ -114,11 +115,14 @@ private fun FeeError(feeSelectorState: FeeSelectorState) {
private fun FeeSelectorState.Content.getAmount(feeType: FeeType): Amount? {
val choosableFees = fees as? TransactionFee.Choosable
val decimals = fees.normal.amount.decimals
val customValue = this.customValues.firstOrNull()?.value?.parseToBigDecimal(decimals)
val customAmount = fees.normal.amount.copy(value = customValue)
return when (feeType) {
FeeType.Slow -> choosableFees?.minimum?.amount
FeeType.Market -> fees.normal.amount
FeeType.Fast -> choosableFees?.priority?.amount
FeeType.Custom -> null
FeeType.Custom -> customAmount
}
}

View file

@ -33,9 +33,10 @@ internal fun TextFieldWithPaste(
) {
val (title, color) = when {
isError && error != null -> error to TangemTheme.colors.text.warning
isReadOnly -> label to TangemTheme.colors.text.disabled
isReadOnly -> label to TangemTheme.colors.text.tertiary
else -> label to TangemTheme.colors.text.secondary
}
val placeholderColor = if (isReadOnly) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.disabled
FooterContainer(modifier, footer) {
Box(
modifier = Modifier
@ -59,6 +60,7 @@ internal fun TextFieldWithPaste(
SimpleTextField(
value = value,
placeholder = placeholder,
placeholderColor = placeholderColor,
onValueChange = onValueChange,
readOnly = isReadOnly,
modifier = Modifier

View file

@ -26,7 +26,7 @@ import com.tangem.features.send.impl.presentation.state.previewdata.AmountStateP
@Composable
internal fun AmountBlock(
amountState: SendStates.AmountState,
isSuccess: Boolean,
isClickDisabled: Boolean,
isEditingDisabled: Boolean,
onClick: () -> Unit,
) {
@ -55,7 +55,7 @@ internal fun AmountBlock(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor)
.clickable(enabled = !isSuccess && !isEditingDisabled, onClick = onClick)
.clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing16,
@ -92,7 +92,7 @@ private fun AmountBlockPreview(@PreviewParameter(AmountBlockPreviewProvider::cla
TangemThemePreview {
AmountBlock(
amountState = value,
isSuccess = false,
isClickDisabled = false,
isEditingDisabled = false,
onClick = {},
)

View file

@ -28,13 +28,13 @@ import com.tangem.features.send.impl.presentation.utils.getCryptoReference
import com.tangem.features.send.impl.presentation.utils.getFiatReference
@Composable
internal fun FeeBlock(feeState: SendStates.FeeState, isSuccess: Boolean, onClick: () -> Unit) {
internal fun FeeBlock(feeState: SendStates.FeeState, isClickDisabled: Boolean, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(enabled = !isSuccess, onClick = onClick)
.clickable(enabled = !isClickDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing12),
) {
Text(
@ -118,7 +118,7 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va
TangemThemePreview {
FeeBlock(
feeState = value,
isSuccess = true,
isClickDisabled = true,
onClick = {},
)
}

View file

@ -25,7 +25,7 @@ import com.tangem.features.send.impl.presentation.state.previewdata.RecipientSta
@Composable
internal fun RecipientBlock(
recipientState: SendStates.RecipientState,
isSuccess: Boolean,
isClickDisabled: Boolean,
isEditingDisabled: Boolean,
onClick: () -> Unit,
) {
@ -40,7 +40,7 @@ internal fun RecipientBlock(
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor)
.clickable(enabled = !isSuccess && !isEditingDisabled, onClick = onClick)
.clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing12),
) {
AddressBlock(recipientState.addressTextField)
@ -107,7 +107,7 @@ private fun RecipientBlockPreview(
TangemThemePreview {
RecipientBlock(
recipientState = value,
isSuccess = true,
isClickDisabled = true,
isEditingDisabled = false,
onClick = {},
)

View file

@ -35,12 +35,13 @@ private const val TAP_HELP_ANIMATION_DELAY = 500L
@Composable
internal fun SendContent(uiState: SendUiState) {
val sendState = uiState.sendState ?: return
val isClickDisabled = sendState.isSending || sendState.isSuccess
LazyColumn(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
blocks(uiState)
tapHelp(isDisplay = sendState.showTapHelp)
notifications(sendState.notifications)
notifications(notifications = sendState.notifications, isClickDisabled = isClickDisabled)
}
}
@ -50,6 +51,7 @@ private fun LazyListScope.blocks(uiState: SendUiState) {
val feeState = uiState.feeState ?: return
val sendState = uiState.sendState ?: return
val isSuccess = sendState.isSuccess
val isClickDisabled = sendState.isSending || isSuccess
val timestamp = sendState.transactionDate
item(key = BLOCKS_KEY) {
@ -65,19 +67,19 @@ private fun LazyListScope.blocks(uiState: SendUiState) {
}
RecipientBlock(
recipientState = recipientState,
isSuccess = isSuccess,
isClickDisabled = isClickDisabled,
isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showRecipient,
)
AmountBlock(
amountState = amountState,
isSuccess = isSuccess,
isClickDisabled = isClickDisabled,
isEditingDisabled = uiState.isEditingDisabled,
onClick = uiState.clickIntents::showAmount,
)
FeeBlock(
feeState = feeState,
isSuccess = isSuccess,
isClickDisabled = isClickDisabled,
onClick = uiState.clickIntents::showFee,
)
}

View file

@ -11,6 +11,7 @@ import java.math.BigDecimal
import java.math.RoundingMode
private const val FIAT_DECIMALS = 2
private const val CRYPTO_FEE_DECIMALS = 6
private const val FEE_MINIMUM_VALUE = 0.01
internal fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): TextReference? {
@ -21,7 +22,7 @@ internal fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): Tex
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amount.value,
cryptoCurrency = amount.currencySymbol,
decimals = amount.decimals,
decimals = amount.decimals.coerceAtMost(CRYPTO_FEE_DECIMALS),
),
),
)
@ -36,24 +37,27 @@ internal fun getFiatReference(value: BigDecimal?, rate: BigDecimal?, appCurrency
internal fun getFiatString(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurrency): String {
if (value == null || rate == null) return EMPTY_BALANCE_SIGN
val feeValue = value.multiply(rate)
val scaled = feeValue.setScale(FIAT_DECIMALS, RoundingMode.UP) ?: BigDecimal.ZERO
val formattedValue = if (scaled < BigDecimal(FEE_MINIMUM_VALUE)) {
return getFiatFormatted(feeValue, appCurrency.code, appCurrency.symbol)
}
internal fun getFiatFormatted(value: BigDecimal?, currencyCode: String, currencySymbol: String): String {
val scaled = value?.setScale(FIAT_DECIMALS, RoundingMode.UP) ?: BigDecimal.ZERO
return if (scaled < BigDecimal(FEE_MINIMUM_VALUE)) {
buildString {
append(BigDecimalFormatter.CAN_BE_LOWER_SIGN)
append(
BigDecimalFormatter.formatFiatAmount(
fiatAmount = BigDecimal(FEE_MINIMUM_VALUE),
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = currencySymbol,
),
)
}
} else {
BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeValue,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
fiatAmount = value,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = currencySymbol,
)
}
return formattedValue
}

View file

@ -26,14 +26,10 @@ 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.model.Network
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.CreateTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.models.UserWallet
@ -59,8 +55,9 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.math.BigDecimal
import java.util.Locale
@ -79,8 +76,8 @@ internal class SendViewModel @Inject constructor(
private val getWalletsUseCase: GetWalletsUseCase,
private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val getCryptoCurrencyStatusesSyncUseCase: GetCryptoCurrencyStatusesSyncUseCase,
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
@ -95,6 +92,8 @@ internal class SendViewModel @Inject constructor(
private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
validateTransactionUseCase: ValidateTransactionUseCase,
currencyChecksRepository: CurrencyChecksRepository,
isFeeApproximateUseCase: IsFeeApproximateUseCase,
validateWalletMemoUseCase: ValidateWalletMemoUseCase,
@ -173,6 +172,7 @@ internal class SendViewModel @Inject constructor(
clickIntents = this,
analyticsEventHandler = analyticsEventHandler,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
validateTransactionUseCase = validateTransactionUseCase,
)
private val sendScreenAnalyticSender by lazy(LazyThreadSafetyMode.NONE) {
@ -180,6 +180,7 @@ internal class SendViewModel @Inject constructor(
stateRouterProvider = Provider { stateRouter },
currentStateProvider = Provider { uiState },
analyticsEventHandler = analyticsEventHandler,
cryptoCurrencyProvider = Provider { cryptoCurrency },
)
}
@ -188,6 +189,7 @@ internal class SendViewModel @Inject constructor(
private set
private var userWallet: UserWallet by Delegates.notNull()
private var userWallets: List<AvailableWallet> = emptyList()
private var isAmountSubtractAvailable: Boolean = false
private var isTapHelpPreviewEnabled: Boolean = false
private var coinCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
@ -205,7 +207,6 @@ internal class SendViewModel @Inject constructor(
private var sendIdleTimer = 0L
init {
subscribeOnQRScannerResult()
subscribeOnCurrencyStatusUpdates()
subscribeOnBalanceHidden()
getTapHelpPreviewAvailability()
@ -372,7 +373,7 @@ internal class SendViewModel @Inject constructor(
cryptoCurrencyStatus = currencyStatus
coinCryptoCurrencyStatus = coinCurrencyStatus
feeCryptoCurrencyStatus = feeCurrencyStatus
subscribeOnQRScannerResult()
when {
uiState.sendState?.isSuccess == true -> {
stateRouter.showSend()
@ -400,57 +401,48 @@ internal class SendViewModel @Inject constructor(
}
private fun getUserWallets() {
getWalletsUseCase()
.conflate()
.distinctUntilChanged()
.onEach { userWallets ->
coroutineScope {
runCatching {
userWallets
.filterNot { it.walletId == userWalletId || it.isLocked }
.map { wallet ->
async(dispatchers.io) { wallet.toAvailableWallet() }
}.awaitAll()
}.onSuccess { result ->
uiState = stateFactory.onLoadedWalletsList(wallets = result)
}.onFailure {
uiState = stateFactory.onLoadedWalletsList(wallets = emptyList())
viewModelScope.launch(dispatchers.main) {
runCatching {
getWalletsUseCase.invokeSync()
?.toAvailableWallets()
.orEmpty()
}.onSuccess { result ->
combine(*result.toTypedArray()) { it }
.onEach {
userWallets = it.filterNotNull().toList()
uiState = stateFactory.onLoadedWalletsList(wallets = userWallets)
}
}
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
}
private suspend fun UserWallet.toAvailableWallet(): AvailableWallet? {
return if (!isMultiCurrency) {
val status = getCryptoCurrencyStatusSyncUseCase(walletId).getOrNull()
val address = status?.value?.networkAddress.takeIf {
status?.currency?.network?.id == cryptoCurrency.network.id &&
status.currency.network.derivationPath !is Network.DerivationPath.Custom
}
address?.let {
AvailableWallet(
name = name,
address = it.defaultAddress.value,
)
}
} else {
val statuses = getCryptoCurrencyStatusesSyncUseCase(walletId).getOrNull()
val walletCurrency = statuses?.firstOrNull {
it.currency.network.id == cryptoCurrency.network.id &&
it.currency.network.derivationPath !is Network.DerivationPath.Custom
}
val address = walletCurrency?.value?.networkAddress
address?.let {
AvailableWallet(
name = name,
address = it.defaultAddress.value,
)
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
}.onFailure {
uiState = stateFactory.onLoadedWalletsList(wallets = emptyList())
}
}
}
private suspend fun List<UserWallet>.toAvailableWallets(): List<Flow<AvailableWallet?>> =
filterNot { it.walletId == userWalletId || it.isLocked }
.mapNotNull { wallet ->
val status = if (!wallet.isMultiCurrency) {
getCryptoCurrencyUseCase(wallet.walletId).getOrNull()?.let {
if (it.network.id == cryptoCurrency.network.id) {
getNetworkAddressesUseCase(wallet.walletId, it.network)
} else {
null
}
}
} else {
getNetworkAddressesUseCase(wallet.walletId, cryptoCurrency.network)
}
status?.map { address ->
AvailableWallet(
name = wallet.name,
address = address,
userWalletId = wallet.walletId,
)
}
}
private suspend fun getTxHistory() {
val txHistoryList = getFixedTxHistoryItemsUseCase.getSync(
userWalletId = userWalletId,
@ -637,7 +629,7 @@ internal class SendViewModel @Inject constructor(
}.saveIn(memoValidationJobHolder)
}
private suspend fun validateAddress(value: String): Boolean {
private suspend fun validateAddress(value: String): Boolean = runCatching {
val isValidAddress = validateWalletAddressUseCase(
userWalletId = userWalletId,
network = cryptoCurrency.network,
@ -647,7 +639,7 @@ internal class SendViewModel @Inject constructor(
?.any { it.value == value } ?: true
onEnteredValidAddress(isValidAddress, isAddressInWallet)
return isValidAddress
}
}.getOrElse { false }
private suspend fun checkIfXrpAddressValue(value: String): Boolean {
return BlockchainUtils.decodeRippleXAddress(value, cryptoCurrency.network.id.value)?.let { decodedAddress ->
@ -875,11 +867,25 @@ internal class SendViewModel @Inject constructor(
uiState = stateFactory.getSendingStateUpdate(isSending = false)
updateTransactionStatus(txData)
scheduleBalanceUpdate()
analyticsEventHandler.send(SendAnalyticEvents.TransactionScreenOpened)
addTokenToWalletIfNeeded()
sendScreenAnalyticSender.sendTransaction()
},
)
}
private fun addTokenToWalletIfNeeded() {
if (cryptoCurrency !is CryptoCurrency.Token) return
val recipientState = uiState.getRecipientState(stateRouter.isEditState) ?: return
val destinationAddress = recipientState.addressTextField.value
val maybeUserWallet = userWallets.firstOrNull { it.address == destinationAddress } ?: return
viewModelScope.launch(dispatchers.io) {
addCryptoCurrenciesUseCase(userWalletId = maybeUserWallet.userWalletId, currency = cryptoCurrency)
}
}
private suspend fun updateTransactionStatus(txData: TransactionData) {
val txUrl = getExplorerTransactionUrlUseCase(
userWalletId = userWalletId,

View file

@ -47,5 +47,6 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.timber)
implementation(deps.tangem.blockchain)
implementation(deps.tangem.card.core)
implementation(deps.moshi)
}

View file

@ -9,4 +9,13 @@ sealed class Warning {
data class MinAmountWarning(val dustValue: BigDecimal) : Warning()
data class ReduceAmountWarning(val tezosFeeThreshold: BigDecimal) : Warning()
sealed class Cardano : Warning() {
data class MinAdaValueCharged(val tokenName: String, val minAdaValue: String) : Cardano()
data object InsufficientBalanceToTransferCoin : Cardano()
data class InsufficientBalanceToTransferToken(val tokenName: String) : Cardano()
}
}

View file

@ -5,9 +5,11 @@ import arrow.core.getOrElse
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.minimalAmount
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
@ -21,6 +23,8 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.CreateTransactionUseCase
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
@ -36,15 +40,12 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.TransactionManager
import com.tangem.lib.crypto.UserWalletManager
import com.tangem.lib.crypto.models.AnalyticsData
import com.tangem.lib.crypto.models.ApproveTxData
import com.tangem.lib.crypto.models.ProxyAmount
import com.tangem.lib.crypto.models.ProxyFees
import com.tangem.lib.crypto.models.*
import com.tangem.lib.crypto.models.transactions.SendTxResult
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.isNullOrZero
import com.tangem.utils.toFiatString
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.firstOrNull
@ -73,6 +74,7 @@ internal class SwapInteractorImpl @Inject constructor(
private val currenciesRepository: CurrenciesRepository,
private val initialToCurrencyResolver: InitialToCurrencyResolver,
private val demoConfig: DemoConfig,
private val transactionRepository: TransactionRepository,
) : SwapInteractor {
private val estimateFeeUseCase by lazy(LazyThreadSafetyMode.NONE) {
@ -350,6 +352,7 @@ internal class SwapInteractorImpl @Inject constructor(
isAllowedToSpend = isAllowedToSpend,
isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough,
txFee = TxFeeState.Empty,
transactionFee = null,
includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex
)
}
@ -379,6 +382,7 @@ internal class SwapInteractorImpl @Inject constructor(
fromTokenStatus: CryptoCurrencyStatus,
amount: SwapAmount,
feeState: TxFeeState,
minAdaValue: BigDecimal?,
): List<Warning> {
val fromToken = fromTokenStatus.currency
val userWalletId = getSelectedWallet()?.walletId ?: return emptyList()
@ -386,6 +390,13 @@ internal class SwapInteractorImpl @Inject constructor(
manageExistentialDepositWarning(warnings, userWalletId, amount, fromToken)
manageDustWarning(warnings, feeState, userWalletId, fromTokenStatus, amount)
manageReduceAmountWarning(warnings, fromTokenStatus, amount)
manageCardanoTransactionValidationWarnings(
warnings = warnings,
fromToken = fromToken,
amount = amount,
userWalletId = userWalletId,
minAdaValue = minAdaValue,
)
return warnings
}
@ -414,23 +425,35 @@ internal class SwapInteractorImpl @Inject constructor(
fromTokenStatus: CryptoCurrencyStatus,
amount: SwapAmount,
) {
if (BlockchainUtils.isCardano(fromTokenStatus.currency.network.id.value)) return
val fee = when (feeState) {
TxFeeState.Empty -> BigDecimal.ZERO
is TxFeeState.MultipleFeeState -> feeState.priorityFee.feeValue
is TxFeeState.SingleFeeState -> feeState.fee.feeValue
}
val dust = currencyChecksRepository.getDustValue(userWalletId, fromTokenStatus.currency.network)
val balance = fromTokenStatus.value.amount ?: BigDecimal.ZERO
if (dust != null &&
!balance.isNullOrZero() &&
amount.value < balance
) {
val change = balance - (amount.value + fee)
val isChangeLowerThanDust = change < dust && change != BigDecimal.ZERO
val isShowWarning = amount.value + fee < dust || isChangeLowerThanDust
if (isShowWarning) {
warnings.add(Warning.MinAmountWarning(dust))
val dustValue = currencyChecksRepository.getDustValue(userWalletId, fromTokenStatus.currency.network) ?: return
val change = when (fromTokenStatus.currency) {
is CryptoCurrency.Coin -> {
val balance = fromTokenStatus.value.amount ?: BigDecimal.ZERO
balance - (fee + amount.value)
}
is CryptoCurrency.Token -> {
val nativeTokenBalance = userWalletManager.getNativeTokenBalance(
fromTokenStatus.currency.network.id.value,
fromTokenStatus.currency.network.derivationPath.value,
)
nativeTokenBalance?.value?.minus(fee) ?: BigDecimal.ZERO
}
}
val isChangeLowerThanDust = change < dustValue && change > BigDecimal.ZERO
if (amount.value < dustValue || isChangeLowerThanDust) {
warnings.add(Warning.MinAmountWarning(dustValue))
}
}
@ -445,6 +468,72 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private suspend fun manageCardanoTransactionValidationWarnings(
warnings: MutableList<Warning>,
fromToken: CryptoCurrency,
amount: SwapAmount,
userWalletId: UserWalletId,
minAdaValue: BigDecimal?,
) {
transactionRepository.validateTransaction(
amount = amount.value.convertToAmount(fromToken),
fee = null,
memo = null,
destination = getTokenAddress(fromToken),
userWalletId = userWalletId,
network = fromToken.network,
)
.fold(
onFailure = {
addCardanoTransactionValidationError(
warnings = warnings,
error = it as? BlockchainSdkError.Cardano ?: return@fold,
fromToken = fromToken,
userWalletId = userWalletId,
)
},
onSuccess = {
minAdaValue?.let {
warnings.add(
Warning.Cardano.MinAdaValueCharged(
tokenName = fromToken.name,
minAdaValue = minAdaValue.parseBigDecimal(fromToken.decimals),
),
)
}
},
)
}
private suspend fun addCardanoTransactionValidationError(
warnings: MutableList<Warning>,
error: BlockchainSdkError.Cardano,
fromToken: CryptoCurrency,
userWalletId: UserWalletId,
) {
when (error) {
BlockchainSdkError.Cardano.InsufficientMinAdaBalanceToSendToken -> {
Warning.Cardano.InsufficientBalanceToTransferToken(fromToken.name)
}
BlockchainSdkError.Cardano.InsufficientRemainingBalanceToWithdrawTokens -> {
when (fromToken) {
is CryptoCurrency.Coin -> Warning.Cardano.InsufficientBalanceToTransferCoin
is CryptoCurrency.Token -> {
Warning.Cardano.InsufficientBalanceToTransferToken(fromToken.name)
}
}
}
BlockchainSdkError.Cardano.InsufficientRemainingBalance,
BlockchainSdkError.Cardano.InsufficientSendingAdaAmount,
-> {
val dustValue = currencyChecksRepository.getDustValue(userWalletId, fromToken.network) ?: return
Warning.MinAmountWarning(dustValue)
}
}
.let(warnings::add) // add warning to the list
}
override suspend fun onSwap(
swapProvider: SwapProvider,
swapData: SwapDataModel?,
@ -841,8 +930,16 @@ internal class SwapInteractorImpl @Inject constructor(
val fromToken = fromTokenStatus.currency
val toToken = toTokenStatus.currency
return coroutineScope {
val txFeeResult = getSelectedWalletSyncUseCase().getOrNull()?.walletId?.let { userWalletId ->
getUnhandledFee(
amount = amount.value,
userWalletId = userWalletId,
cryptoCurrency = fromToken,
)
}
val txFee = if (provider.type == ExchangeProviderType.CEX) {
getFeeForCex(amount, fromTokenStatus)
getFeeForCex(txFeeResult, fromTokenStatus)
} else {
TxFeeState.Empty
}
@ -881,11 +978,13 @@ internal class SwapInteractorImpl @Inject constructor(
isAllowedToSpend = isAllowedToSpend,
isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough,
txFee = txFee,
transactionFee = txFeeResult?.getOrNull(),
includeFeeInAmount = includeFeeInAmount,
)
}
}
@Suppress("LongMethod")
private suspend fun getQuotesState(
exchangeProviderType: ExchangeProviderType,
quoteDataModel: Either<DataError, QuoteModel>,
@ -896,6 +995,7 @@ internal class SwapInteractorImpl @Inject constructor(
isAllowedToSpend: Boolean,
isBalanceWithoutFeeEnough: Boolean,
txFee: TxFeeState,
transactionFee: TransactionFee?,
includeFeeInAmount: IncludeFeeInAmount,
): SwapState {
return quoteDataModel.fold(
@ -909,7 +1009,12 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = null,
txFeeState = txFee,
).copy(
warnings = manageWarnings(fromToken, amount, txFee),
warnings = manageWarnings(
fromTokenStatus = fromToken,
amount = amount,
feeState = txFee,
minAdaValue = (transactionFee?.normal as? Fee.CardanoToken)?.minAdaValue,
),
)
when (exchangeProviderType) {
@ -1113,7 +1218,14 @@ internal class SwapInteractorImpl @Inject constructor(
)
swapState.copy(
permissionState = PermissionDataState.Empty,
warnings = manageWarnings(fromToken, amount, txFeeState),
warnings = manageWarnings(
fromToken,
amount,
txFeeState,
(feeData as? ProxyFees.SingleFee)?.let {
(it.singleFee as? ProxyFee.CardanoToken)?.minAdaValue
},
),
preparedSwapConfigState = PreparedSwapConfigState(
isAllowedToSpend = true,
isBalanceEnough = isBalanceIncludeFeeEnough,
@ -1179,23 +1291,26 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun getFeeForCex(amount: SwapAmount, fromToken: CryptoCurrencyStatus): TxFeeState {
getSelectedWalletSyncUseCase().getOrNull()?.walletId?.let { userWalletId ->
val txFeeResult = estimateFeeUseCase(
amount = amount.value,
userWalletId = userWalletId,
cryptoCurrency = fromToken.currency,
).firstOrNull()
return txFeeResult?.fold(
ifLeft = {
TxFeeState.Empty
},
ifRight = { txFee ->
txFee.toTxFeeState(fromToken.currency)
},
) ?: TxFeeState.Empty
}
return TxFeeState.Empty
private suspend fun getFeeForCex(
txFeeResult: Either<GetFeeError, TransactionFee>?,
fromToken: CryptoCurrencyStatus,
): TxFeeState {
return txFeeResult?.fold(
ifLeft = { TxFeeState.Empty },
ifRight = { txFee -> txFee.toTxFeeState(fromToken.currency) },
) ?: TxFeeState.Empty
}
private suspend fun getUnhandledFee(
amount: BigDecimal,
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Either<GetFeeError, TransactionFee>? {
return estimateFeeUseCase(
amount = amount,
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
).firstOrNull()
}
@Suppress("LongParameterList", "LongMethod")

View file

@ -50,6 +50,7 @@ class SwapDomainModule {
coroutineDispatcherProvider: CoroutineDispatcherProvider,
initialToCurrencyResolver: InitialToCurrencyResolver,
currenciesRepository: CurrenciesRepository,
transactionRepository: TransactionRepository,
): SwapInteractor {
return SwapInteractorImpl(
transactionManager = transactionManager,
@ -69,6 +70,7 @@ class SwapDomainModule {
currenciesRepository = currenciesRepository,
initialToCurrencyResolver = initialToCurrencyResolver,
demoConfig = DemoConfig(),
transactionRepository = transactionRepository,
)
}

View file

@ -28,6 +28,7 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.settings)
@ -56,6 +57,9 @@ dependencies {
implementation(projects.features.swap.api)
implementation(projects.features.tokendetails.api)
/** Libs */
implementation(projects.libs.crypto)
/** Other libraries */
implementation(deps.compose.shimmer)
implementation(deps.compose.accompanist.webView)
@ -67,5 +71,4 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -110,6 +110,7 @@ sealed interface SwapWarning {
val type: GenericWarningType = GenericWarningType.OTHER,
val onClick: () -> Unit,
) : SwapWarning
data class GeneralError(val notificationConfig: NotificationConfig) : SwapWarning
data class UnableToCoverFeeWarning(val notificationConfig: NotificationConfig) : SwapWarning
data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning
@ -117,6 +118,16 @@ sealed interface 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
sealed interface Cardano : SwapWarning {
val notificationConfig: NotificationConfig
data class MinAdaValueCharged(override val notificationConfig: NotificationConfig) : Cardano
data class InsufficientBalanceToTransferCoin(override val notificationConfig: NotificationConfig) : Cardano
data class InsufficientBalanceToTransferToken(override val notificationConfig: NotificationConfig) : Cardano
}
}
enum class GenericWarningType {

View file

@ -6,11 +6,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import com.tangem.core.navigation.ReduxNavController
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.haptic.HapticManager
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.swap.router.CustomTabsManager
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.router.SwapRouter
@ -26,10 +25,7 @@ import javax.inject.Inject
class SwapFragment : ComposeFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
@Inject
override lateinit var hapticManager: HapticManager
override lateinit var uiDependencies: UiDependencies
@Inject
lateinit var reduxNavController: ReduxNavController

View file

@ -216,6 +216,7 @@ internal class StateBuilder(
val warnings = getWarningsForSuccessState(
quoteModel = quoteModel,
fromToken = fromToken,
selectedFeeType = selectedFeeType,
)
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
@ -314,12 +315,13 @@ internal class StateBuilder(
private fun getWarningsForSuccessState(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
selectedFeeType: FeeType,
): List<SwapWarning> {
val warnings = mutableListOf<SwapWarning>()
maybeAddDomainWarnings(quoteModel, warnings)
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
maybeAddInsufficientFundsWarning(quoteModel, warnings)
maybeAddTransactionInProgressWarning(quoteModel, warnings)
@ -405,6 +407,15 @@ internal class StateBuilder(
),
)
}
Warning.Cardano.InsufficientBalanceToTransferCoin -> {
warnings.add(createInsufficientBalanceToTransferCoin())
}
is Warning.Cardano.InsufficientBalanceToTransferToken -> {
warnings.add(createInsufficientBalanceToTransferToken(tokenName = it.tokenName))
}
is Warning.Cardano.MinAdaValueCharged -> {
warnings.add(createMinAdaValueCharged(minAdaValue = it.minAdaValue, tokenName = it.tokenName))
}
}
}
}
@ -451,18 +462,37 @@ internal class StateBuilder(
private fun maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
selectedFeeType: FeeType,
) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included ->
is IncludeFeeInAmount.Included -> {
val fee = selectFeeByType(selectedFeeType, quoteModel.txFee) ?: return
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
createNetworkFeeCoverageNotificationConfig(
quoteModel.fromTokenInfo.tokenAmount.getFormattedCryptoAmount(
quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency,
),
fee.feeFiatFormatted,
),
),
)
}
else -> Unit
}
}
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? {
return when (txFeeState) {
TxFeeState.Empty -> null
is TxFeeState.SingleFeeState -> txFeeState.fee
is TxFeeState.MultipleFeeState -> when (feeType) {
FeeType.NORMAL -> txFeeState.normalFee
FeeType.PRIORITY -> txFeeState.priorityFee
}
}
}
private fun maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
@ -538,12 +568,12 @@ internal class StateBuilder(
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = mutableListOf<SwapWarning>()
warnings.add(getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency))
if (includeFeeInAmount is IncludeFeeInAmount.Included) {
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(),
),
if (includeFeeInAmount is IncludeFeeInAmount.Included && uiStateHolder.fee is FeeItemState.Content) {
val feeCoverageNotification = createNetworkFeeCoverageNotificationConfig(
fromToken.tokenAmount.getFormattedCryptoAmount(fromToken.cryptoCurrencyStatus.currency),
uiStateHolder.fee.amountFiatFormatted,
)
warnings.add(SwapWarning.GeneralWarning(feeCoverageNotification))
}
val providerState = getProviderStateForError(
swapProvider = swapProvider,
@ -1393,13 +1423,55 @@ internal class StateBuilder(
)
}
private fun createNetworkFeeCoverageNotificationConfig(): NotificationConfig {
private fun createNetworkFeeCoverageNotificationConfig(
cryptoAmount: String,
fiatAmount: String,
): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(R.string.swapping_network_fee_warning_content),
subtitle = resourceReference(
R.string.common_network_fee_warning_content,
wrappedList(cryptoAmount, fiatAmount),
),
iconResId = R.drawable.img_attention_20,
)
}
private fun createMinAdaValueCharged(minAdaValue: String, tokenName: String): SwapWarning {
return SwapWarning.Cardano.MinAdaValueCharged(
NotificationConfig(
title = resourceReference(id = R.string.cardano_coin_will_be_send_with_token_title),
subtitle = resourceReference(
id = R.string.cardano_coin_will_be_send_with_token_description,
formatArgs = wrappedList(minAdaValue, tokenName),
),
iconResId = R.drawable.img_attention_20,
),
)
}
private fun createInsufficientBalanceToTransferCoin(): SwapWarning {
return SwapWarning.Cardano.InsufficientBalanceToTransferCoin(
NotificationConfig(
title = resourceReference(id = R.string.cardano_max_amount_has_token_title),
subtitle = resourceReference(id = R.string.cardano_max_amount_has_token_description),
iconResId = R.drawable.img_attention_20,
),
)
}
private fun createInsufficientBalanceToTransferToken(tokenName: String): SwapWarning {
return SwapWarning.Cardano.InsufficientBalanceToTransferToken(
NotificationConfig(
title = resourceReference(id = R.string.cardano_insufficient_balance_to_send_token_title),
subtitle = resourceReference(
id = R.string.cardano_insufficient_balance_to_send_token_description,
formatArgs = wrappedList(tokenName),
),
iconResId = R.drawable.img_attention_20,
),
)
}
// end region
private fun getShortAddressValue(fullAddress: String): String {

View file

@ -391,7 +391,8 @@ private fun SwapWarnings(warnings: List<SwapWarning>) {
},
)
}
else -> {}
is SwapWarning.Cardano -> Notification(config = warning.notificationConfig)
SwapWarning.InsufficientFunds -> Unit
}
SpacerH8()
}

View file

@ -6,11 +6,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeActivity
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.feature.tester.presentation.actions.TesterActionsScreen
import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel
import com.tangem.feature.tester.presentation.featuretoggles.ui.FeatureTogglesScreen
@ -28,10 +27,7 @@ import javax.inject.Inject
internal class TesterActivity : ComposeActivity() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
@Inject
override lateinit var hapticManager: HapticManager
override lateinit var uiDependencies: UiDependencies
/** Router for inner feature navigation */
@Inject

View file

@ -4,11 +4,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.hilt.navigation.compose.hiltViewModel
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.haptic.HapticManager
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.tokendetails.presentation.router.InnerTokenDetailsRouter
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsViewModel
@ -20,10 +19,7 @@ import javax.inject.Inject
internal class TokenDetailsFragment : ComposeFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
@Inject
override lateinit var hapticManager: HapticManager
override lateinit var uiDependencies: UiDependencies
@Inject
lateinit var tokenDetailsRouter: TokenDetailsRouter

View file

@ -41,6 +41,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.TopUpWithoutReserve,
is TokenDetailsNotification.RentInfo,
is TokenDetailsNotification.SwapPromo,
is TokenDetailsNotification.NetworkShutdown,
-> null
}
}

View file

@ -155,7 +155,11 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
),
)
class NetworksNoAccount(val network: String, val symbol: String, val amount: String) : Informational(
data class NetworksNoAccount(
private val network: String,
private val symbol: String,
private val amount: String,
) : Informational(
title = resourceReference(R.string.warning_no_account_title),
subtitle = resourceReference(
id = R.string.no_account_generic,
@ -167,4 +171,9 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
title = resourceReference(id = R.string.warning_no_account_title),
subtitle = resourceReference(id = R.string.no_account_send_to_create),
)
data class NetworkShutdown(private val title: TextReference, private val subtitle: TextReference) : Warning(
title = title,
subtitle = subtitle,
)
}

View file

@ -3,12 +3,14 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification.*
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.removeBy
import kotlinx.collections.immutable.ImmutableList
@ -80,6 +82,10 @@ internal class TokenDetailsNotificationConverter(
onSwapClick = clickIntents::onSwapPromoClick,
onCloseClick = clickIntents::onSwapPromoDismiss,
)
is CryptoCurrencyWarning.BeaconChainShutdown -> NetworkShutdown(
title = resourceReference(R.string.warning_beacon_chain_retirement_title),
subtitle = resourceReference(R.string.warning_beacon_chain_retirement_content),
)
}
}

View file

@ -2,11 +2,10 @@ package com.tangem.feature.wallet.presentation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.haptic.HapticManager
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.wallet.presentation.router.InnerWalletRouter
import com.tangem.features.managetokens.navigation.ManageTokensUi
import com.tangem.features.wallet.navigation.WalletRouter
@ -22,10 +21,7 @@ import javax.inject.Inject
internal class WalletFragment : ComposeFragment() {
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
@Inject
override lateinit var hapticManager: HapticManager
override lateinit var uiDependencies: UiDependencies
@Inject
internal lateinit var manageTokensUi: ManageTokensUi

View file

@ -121,4 +121,38 @@ sealed class WalletScreenAnalyticsEvent {
data object DeleteWalletTapped : MainScreen(event = "Button - Delete Wallet Tapped")
}
sealed class Promotion(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Promotion", event = event, params = params) {
class NoticePromotionBanner(
source: AnalyticsParam.ScreensSources,
programName: String,
) : Promotion(
event = "Notice - Promotion Banner",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to programName,
),
)
class PromotionBannerClicked(
source: AnalyticsParam.ScreensSources,
programName: String,
action: BannerAction,
) : Promotion(
event = "Promo Banner Clicked",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to programName,
"Action" to action.action,
),
) {
sealed class BannerAction(val action: String) {
data object Clicked : BannerAction(action = "Clicked")
data object Closed : BannerAction(action = "Closed")
}
}
}
}

View file

@ -33,7 +33,7 @@ internal class TokenListAnalyticsSender @Inject constructor(
private val mutex = Mutex()
suspend fun send(displayedUiState: WalletState?, userWallet: UserWallet, tokenList: TokenList) {
if (screenLifecycleProvider.isBackground) return
if (screenLifecycleProvider.isBackgroundState.value) return
if (displayedUiState == null || displayedUiState.pullToRefreshConfig.isRefreshing) return
if (tokenList.totalFiatBalance is TokenList.FiatBalance.Loading) return

View file

@ -2,6 +2,8 @@ package com.tangem.feature.wallet.presentation.wallet.analytics.utils
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
@ -16,7 +18,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
) {
fun send(displayedUiState: WalletState?, newWarnings: List<WalletNotification>) {
if (screenLifecycleProvider.isBackground) return
if (screenLifecycleProvider.isBackgroundState.value) return
if (newWarnings.isEmpty()) return
if (displayedUiState == null || displayedUiState.pullToRefreshConfig.isRefreshing) return
@ -44,6 +46,10 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.Informational.MissingAddresses -> MainScreen.MissingAddresses
is WalletNotification.RateApp -> MainScreen.HowDoYouLikeTangem
is WalletNotification.Critical.BackupError -> MainScreen.BackupError
is WalletNotification.TravalaPromo -> WalletScreenAnalyticsEvent.Promotion.NoticePromotionBanner(
source = AnalyticsParam.ScreensSources.Main,
programName = "Travala",
)
is WalletNotification.UnlockWallets -> null // See [SelectedWalletAnalyticsSender]
is WalletNotification.Informational.NoAccount,
is WalletNotification.Warning.LowSignatures,

View file

@ -6,7 +6,7 @@ import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.promo.PromoBanner
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase
import com.tangem.domain.settings.ShouldShowTravalaPromoWalletUseCase
import com.tangem.domain.tokens.GetTokenListUseCase
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.CryptoCurrency
@ -34,7 +34,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val getTokenListUseCase: GetTokenListUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase,
private val shouldShowTravalaPromoWalletUseCase: ShouldShowTravalaPromoWalletUseCase,
private val promoRepository: PromoRepository,
private val isNeedToBackupUseCase: IsNeedToBackupUseCase,
private val backupValidator: BackupValidator,
@ -45,18 +45,18 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotification>> {
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
val promoFlow = flow { emit(promoRepository.getChangellyPromoBanner()) }
val travalaPromoFlow = flow { emit(promoRepository.getTravalaPromoBanner()) }
return combine(
flow = getTokenListUseCase.launch(userWallet.walletId).conflate(),
flow2 = isReadyToShowRateAppUseCase().conflate(),
flow3 = isNeedToBackupUseCase(userWallet.walletId).conflate(),
flow4 = shouldShowSwapPromoWalletUseCase().conflate(),
flow5 = promoFlow,
) { maybeTokenList, isReadyToShowRating, isNeedToBackup, shouldShowPromo, promoBanner ->
flow4 = shouldShowTravalaPromoWalletUseCase().conflate(),
flow5 = travalaPromoFlow.conflate(),
) { maybeTokenList, isReadyToShowRating, isNeedToBackup, shouldShowTravalaPromo, promoBanner ->
readyForRateAppNotification = true
buildList {
addSwapPromoNotification(shouldShowPromo, promoBanner, clickIntents)
addTravalaPromoNotification(shouldShowTravalaPromo, promoBanner, clickIntents)
addCriticalNotifications(userWallet)
@ -69,16 +69,18 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
}
}
private fun MutableList<WalletNotification>.addSwapPromoNotification(
private fun MutableList<WalletNotification>.addTravalaPromoNotification(
shouldShowPromo: Boolean,
promoBanner: PromoBanner?,
clickIntents: WalletClickIntents,
) {
promoBanner ?: return
val promoNotification = WalletNotification.SwapPromo(
val promoNotification = WalletNotification.TravalaPromo(
startDateTime = promoBanner.bannerState.timeline.start,
endDateTime = promoBanner.bannerState.timeline.end,
onCloseClick = clickIntents::onCloseSwapPromoClick,
bannerLink = promoBanner.bannerState.link,
onBookNowButtonClick = clickIntents::onTravalaPromoClick,
onCloseClick = clickIntents::onCloseTravalaPromoClick,
)
addIf(
element = promoNotification,

View file

@ -34,6 +34,9 @@ internal object WalletImageResolver {
cardTypesResolver.isJrWallet() -> userWallet.resolveJrWallet()
cardTypesResolver.isGrimWallet() -> userWallet.resolveGrimWallet()
cardTypesResolver.isSatoshiFriendsWallet() -> userWallet.resolveSatoshiWallet()
cardTypesResolver.isBitcoinPizzaDayWallet() -> userWallet.resolveBitcoinPizzaDayWallet()
cardTypesResolver.isVeChainWallet() -> userWallet.resolveVeChainWallet()
cardTypesResolver.isNewWorldEliteWallet() -> userWallet.resolveNewWorldEliteWallet()
cardTypesResolver.isWallet2() -> userWallet.resolveWallet2()
cardTypesResolver.isShibaWallet() -> userWallet.resolveShibaWallet()
cardTypesResolver.isTangemWallet() -> userWallet.resolveWallet1()
@ -130,6 +133,27 @@ internal object WalletImageResolver {
)
}
private fun UserWallet.resolveBitcoinPizzaDayWallet(): Int? {
return resolveWallet2(
oneBackupResId = R.drawable.ill_pizza_day_card2_120_106,
twoBackupResId = R.drawable.ill_pizza_day_card3_120_106,
)
}
private fun UserWallet.resolveVeChainWallet(): Int? {
return resolveWallet2(
oneBackupResId = R.drawable.ill_vechain_card2_120_106,
twoBackupResId = R.drawable.ill_vechain_card3_120_106,
)
}
private fun UserWallet.resolveNewWorldEliteWallet(): Int? {
return resolveWallet2(
oneBackupResId = R.drawable.ill_nwe_card2_120_106,
twoBackupResId = R.drawable.ill_nwe_card3_120_106,
)
}
private fun UserWallet.resolveWallet1(): Int? {
return resolveWalletWithBackups { count ->
when (count) {

View file

@ -2,10 +2,8 @@ package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.feature.wallet.impl.R
import org.joda.time.DateTime
@ -173,6 +171,34 @@ sealed class WalletNotification(val config: NotificationConfig) {
),
)
data class TravalaPromo(
val startDateTime: DateTime,
val endDateTime: DateTime,
val bannerLink: String?,
val onBookNowButtonClick: (String?) -> Unit,
val onCloseClick: () -> Unit,
) : WalletNotification(
config = NotificationConfig(
title = resourceReference(id = R.string.main_travala_promotion_title),
subtitle = resourceReference(
id = R.string.main_travala_promotion_description,
wrappedList(
DateTimeFormatters.formatDate(startDateTime, DateTimeFormatters.dateMMMMd),
DateTimeFormatters.formatDate(endDateTime, DateTimeFormatters.dateMMMMd),
),
),
// Stub. Travala has its own Composable implementation with correct img
iconResId = R.drawable.ic_star_24,
// Stub. Travala has its own Composable implementation with correct img
backgroundResId = R.drawable.ic_star_24,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
onClick = { onBookNowButtonClick(bannerLink) },
text = resourceReference(R.string.main_travala_promotion_button),
),
onCloseClick = onCloseClick,
),
)
data class SwapPromo(
val startDateTime: DateTime,
val endDateTime: DateTime,

View file

@ -6,4 +6,10 @@ package com.tangem.feature.wallet.presentation.wallet.state.model
* @property isRefreshing state is indicator visible
* @property onRefresh lambda be invoked when pulled to refresh
*/
data class WalletPullToRefreshConfig(val isRefreshing: Boolean, val onRefresh: () -> Unit)
data class WalletPullToRefreshConfig(val isRefreshing: Boolean, val onRefresh: (ShowRefreshState) -> Unit) {
@JvmInline
value class ShowRefreshState(
val value: Boolean,
)
}

View file

@ -72,7 +72,7 @@ internal class WalletLoadingStateFactory(private val clickIntents: WalletClickIn
}
private fun createPullToRefreshConfig(): WalletPullToRefreshConfig {
return WalletPullToRefreshConfig(onRefresh = clickIntents::onRefreshSwipe, isRefreshing = false)
return WalletPullToRefreshConfig(onRefresh = { clickIntents.onRefreshSwipe(it.value) }, isRefreshing = false)
}
private fun UserWallet.toLoadingWalletCardState(): WalletCardState {

View file

@ -135,7 +135,9 @@ private fun WalletContent(
.padding(horizontal = horizontalPadding)
LazyColumn(
modifier = Modifier.fillMaxSize().testTag(TestTags.WALLET_SCREEN),
modifier = Modifier
.fillMaxSize()
.testTag(TestTags.WALLET_SCREEN),
contentPadding = PaddingValues(
top = TangemTheme.dimens.spacing8,
bottom = TangemTheme.dimens.spacing92,
@ -317,7 +319,9 @@ private fun BaseScaffoldManageTokenRedesign(
content = { paddingValues ->
val pullRefreshState = rememberPullRefreshState(
refreshing = selectedWallet.pullToRefreshConfig.isRefreshing,
onRefresh = selectedWallet.pullToRefreshConfig.onRefresh,
onRefresh = {
selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true))
},
)
Column(
@ -503,7 +507,9 @@ private fun BaseScaffold(
content = {
val pullRefreshState = rememberPullRefreshState(
refreshing = selectedWallet.pullToRefreshConfig.isRefreshing,
onRefresh = selectedWallet.pullToRefreshConfig.onRefresh,
onRefresh = {
selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true))
},
)
Box(

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationWithBackground
import com.tangem.core.ui.components.notifications.TravalaNotificationWithBackground
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
import kotlinx.collections.immutable.ImmutableList
@ -25,23 +26,33 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
key = { it::class.java },
contentType = { it::class.java },
itemContent = {
if (it is WalletNotification.SwapPromo) {
NotificationWithBackground(
config = it.config,
modifier = modifier.animateItemPlacement(),
)
} else {
Notification(
config = it.config,
modifier = modifier.animateItemPlacement(),
iconTint = when (it) {
is WalletNotification.Critical -> TangemTheme.colors.icon.warning
is WalletNotification.Informational -> TangemTheme.colors.icon.accent
is WalletNotification.RateApp -> TangemTheme.colors.icon.attention
is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1
else -> null
},
)
// TODO develop promo banner general component
when (it) {
is WalletNotification.SwapPromo -> {
NotificationWithBackground(
config = it.config,
modifier = modifier.animateItemPlacement(),
)
}
is WalletNotification.TravalaPromo -> {
TravalaNotificationWithBackground(
config = it.config,
modifier = modifier.animateItemPlacement(),
)
}
else -> {
Notification(
config = it.config,
modifier = modifier.animateItemPlacement(),
iconTint = when (it) {
is WalletNotification.Critical -> TangemTheme.colors.icon.warning
is WalletNotification.Informational -> TangemTheme.colors.icon.accent
is WalletNotification.RateApp -> TangemTheme.colors.icon.attention
is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1
else -> null
},
)
}
}
},
)

View file

@ -3,19 +3,21 @@ package com.tangem.feature.wallet.presentation.wallet.utils
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ViewModelScoped
internal class ScreenLifecycleProvider @Inject constructor() : DefaultLifecycleObserver {
var isBackground: Boolean = true
private set
private val _isBackgroundState = MutableStateFlow(false)
val isBackgroundState: StateFlow<Boolean> = _isBackgroundState
override fun onResume(owner: LifecycleOwner) {
isBackground = false
_isBackgroundState.value = false
}
override fun onPause(owner: LifecycleOwner) {
isBackground = true
_isBackgroundState.value = true
}
}

View file

@ -20,6 +20,7 @@ import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContent
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent.DemonstrateWalletsScrollPreview.Direction
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletPullToRefreshConfig
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.transformers.*
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
@ -31,7 +32,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -61,7 +62,9 @@ internal class WalletViewModel @Inject constructor(
val uiState: StateFlow<WalletScreenState> = stateHolder.uiState
private lateinit var router: InnerWalletRouter
private var walletsUpdateJobHolder: JobHolder = JobHolder()
private val walletsUpdateJobHolder = JobHolder()
private val refreshWalletJobHolder = JobHolder()
private var needToRefreshWallet = false
init {
analyticsEventsHandler.send(WalletScreenAnalyticsEvent.MainScreen.ScreenOpened)
@ -71,6 +74,7 @@ internal class WalletViewModel @Inject constructor(
subscribeToUserWalletsUpdates()
subscribeOnBalanceHiding()
subscribeOnSelectedWalletFlow()
subscribeToScreenBackgroundState()
}
fun setWalletRouter(router: InnerWalletRouter) {
@ -150,6 +154,36 @@ internal class WalletViewModel @Inject constructor(
}
}
// We need to update the current wallet if the application was in the background for more than 10 seconds
// and then returned to the foreground
private fun subscribeToScreenBackgroundState() {
screenLifecycleProvider.isBackgroundState
.onEach { isBackground ->
refreshWalletJobHolder.cancel()
when {
isBackground -> needToRefreshTimer()
needToRefreshWallet && !isBackground -> triggerRefreshWallet()
}
}
.launchIn(viewModelScope)
}
private fun needToRefreshTimer() {
viewModelScope.launch {
delay(REFRESH_WALLET_BACKGROUND_TIMER_MILLIS)
needToRefreshWallet = true
}.saveIn(refreshWalletJobHolder)
}
private fun triggerRefreshWallet() {
needToRefreshWallet = false
val state = stateHolder.uiState.value
val wallet = state.wallets.getOrNull(state.selectedWalletIndex) ?: return
wallet.pullToRefreshConfig.onRefresh.invoke(
WalletPullToRefreshConfig.ShowRefreshState(false),
)
}
private suspend fun updateWallets(action: WalletsUpdateActionResolver.Action) {
when (action) {
is WalletsUpdateActionResolver.Action.InitializeWallets -> initializeWallets(action)
@ -280,7 +314,7 @@ internal class WalletViewModel @Inject constructor(
}
private fun closeScreen(screen: AppScreen) {
if (!screenLifecycleProvider.isBackground) {
if (!screenLifecycleProvider.isBackgroundState.value) {
stateHolder.clear()
router.popBackStack(screen = screen)
}
@ -296,4 +330,8 @@ internal class WalletViewModel @Inject constructor(
),
)
}
private companion object {
const val REFRESH_WALLET_BACKGROUND_TIMER_MILLIS = 10000L
}
}

View file

@ -1,8 +1,12 @@
package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.card.DeleteSavedAccessCodesUseCase
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.UpdateWalletUseCase
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
@ -32,9 +36,13 @@ internal class WalletCardClickIntentsImplementor @Inject constructor(
private val stateHolder: WalletStateController,
private val walletEventSender: WalletEventSender,
private val walletScreenContentLoader: WalletScreenContentLoader,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val updateWalletUseCase: UpdateWalletUseCase,
private val deleteWalletUseCase: DeleteWalletUseCase,
private val deleteSavedAccessCodesUseCase: DeleteSavedAccessCodesUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val reduxStateHolder: ReduxStateHolder,
private val dispatchers: CoroutineDispatcherProvider,
) : BaseWalletClickIntents(), WalletCardClickIntents {
@ -72,7 +80,18 @@ internal class WalletCardClickIntentsImplementor @Inject constructor(
override fun onDeleteAfterConfirmationClick(userWalletId: UserWalletId) {
viewModelScope.launch(dispatchers.main) {
walletScreenContentLoader.cancel(userWalletId)
val deletedUserWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return@launch
deleteSavedAccessCodesUseCase(cardId = deletedUserWallet.cardId)
.onLeft { Timber.e(it.toString()) }
deleteWalletUseCase(userWalletId)
.onRight {
getSelectedWalletSyncUseCase().getOrNull()?.let {
reduxStateHolder.onUserWalletSelected(it)
}
}
.onLeft { Timber.e(it.toString()) }
}
}

View file

@ -77,17 +77,17 @@ internal class WalletClickIntents @Inject constructor(
}
}
fun onRefreshSwipe() {
fun onRefreshSwipe(showRefreshState: Boolean) {
when (stateHolder.getSelectedWallet()) {
is WalletState.MultiCurrency.Content -> {
analyticsEventHandler.send(PortfolioEvent.Refreshed)
refreshMultiCurrencyContent()
refreshMultiCurrencyContent(showRefreshState)
}
is WalletState.SingleCurrency.Content,
is WalletState.Visa.Content,
-> {
analyticsEventHandler.send(PortfolioEvent.Refreshed)
refreshSingleCurrencyContent()
refreshSingleCurrencyContent(showRefreshState)
}
is WalletState.MultiCurrency.Locked,
is WalletState.SingleCurrency.Locked,
@ -97,14 +97,14 @@ internal class WalletClickIntents @Inject constructor(
}
fun onReloadClick() {
refreshSingleCurrencyContent()
refreshSingleCurrencyContent(showRefreshState = true)
}
private fun refreshMultiCurrencyContent() {
private fun refreshMultiCurrencyContent(showRefreshState: Boolean) {
val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return
stateHolder.update(
SetRefreshStateTransformer(userWalletId = userWallet.walletId, isRefreshing = true),
SetRefreshStateTransformer(userWalletId = userWallet.walletId, isRefreshing = showRefreshState),
)
viewModelScope.launch(dispatchers.main) {
@ -126,11 +126,11 @@ internal class WalletClickIntents @Inject constructor(
// FIXME: refreshSingleCurrencyContent mustn't update the TxHistory and Buttons. It only must fetch primary
// currency. Now it not works because GetPrimaryCurrency's subscriber uses .distinctUntilChanged()
private fun refreshSingleCurrencyContent() {
private fun refreshSingleCurrencyContent(showRefreshState: Boolean) {
val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return
stateHolder.update(
SetRefreshStateTransformer(userWalletId = userWallet.walletId, isRefreshing = true),
SetRefreshStateTransformer(userWalletId = userWallet.walletId, isRefreshing = showRefreshState),
)
viewModelScope.launch(dispatchers.main) {

View file

@ -11,6 +11,7 @@ import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.settings.NeverToSuggestRateAppUseCase
import com.tangem.domain.settings.RemindToRateAppLaterUseCase
import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase
import com.tangem.domain.settings.ShouldShowTravalaPromoWalletUseCase
import com.tangem.domain.tokens.FetchTokenListUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType
@ -19,6 +20,7 @@ import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.UnlockWalletsUseCase
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.domain.ScanCardToUnlockWalletClickHandler
@ -56,6 +58,10 @@ internal interface WalletWarningsClickIntents {
fun onCloseRateAppWarningClick()
fun onCloseSwapPromoClick()
fun onTravalaPromoClick(link: String?)
fun onCloseTravalaPromoClick()
}
@Suppress("LongParameterList")
@ -75,6 +81,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
private val reduxStateHolder: ReduxStateHolder,
private val dispatchers: CoroutineDispatcherProvider,
private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase,
private val shouldShowTravalaPromoWalletUseCase: ShouldShowTravalaPromoWalletUseCase,
) : BaseWalletClickIntents(), WalletWarningsClickIntents {
override fun onAddBackupCardClick() {
@ -212,6 +219,34 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
}
}
override fun onTravalaPromoClick(link: String?) {
analyticsEventHandler.send(
WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked(
source = AnalyticsParam.ScreensSources.Main,
programName = "Travala",
action = WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked.BannerAction.Clicked,
),
)
link?.let {
viewModelScope.launch(dispatchers.main) {
router.openUrl(link)
}
}
}
override fun onCloseTravalaPromoClick() {
analyticsEventHandler.send(
WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked(
source = AnalyticsParam.ScreensSources.Main,
programName = "Travala",
action = WalletScreenAnalyticsEvent.Promotion.PromotionBannerClicked.BannerAction.Closed,
),
)
viewModelScope.launch(dispatchers.main) {
shouldShowTravalaPromoWalletUseCase.neverToShow()
}
}
private suspend fun getSelectedUserWallet(): UserWallet? {
val userWalletId = stateHolder.getSelectedWalletId()
return getUserWalletUseCase(userWalletId).getOrElse {

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB