Updated on 2026-08-14

This commit is contained in:
Tangem 2024-01-31 14:08:06 +03:00
commit 8dec159cd3
126 changed files with 3168 additions and 996 deletions

View file

@ -62,6 +62,7 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.deepLinks)
implementation(projects.libs.crypto)
implementation(projects.libs.auth)

View file

@ -23,9 +23,9 @@ import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import arrow.core.getOrElse
import by.kirich1409.viewbindingdelegate.viewBinding
import com.tangem.feature.qrscanning.QrScanningRouter
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.core.ui.event.StateEvent
@ -36,6 +36,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.features.managetokens.navigation.ManageTokensRouter
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.tester.api.TesterRouter
@ -57,8 +58,6 @@ import com.tangem.tap.domain.userWalletList.implementation.BiometricUserWalletsL
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor
import com.tangem.tap.features.intentHandler.IntentProcessor
import com.tangem.tap.features.intentHandler.handlers.BackgroundScanIntentHandler
import com.tangem.tap.features.intentHandler.handlers.BuyCurrencyIntentHandler
import com.tangem.tap.features.intentHandler.handlers.SellCurrencyIntentHandler
import com.tangem.tap.features.intentHandler.handlers.WalletConnectLinkIntentHandler
import com.tangem.tap.features.main.MainViewModel
import com.tangem.tap.features.main.model.Toast
@ -138,6 +137,9 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
@Inject
lateinit var qrScanningRouter: QrScanningRouter
@Inject
lateinit var deepLinksRegistry: DeepLinksRegistry
internal val viewModel: MainViewModel by viewModels()
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode?>
@ -167,6 +169,10 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
checkForNotificationPermission()
observeStateUpdates()
if (intent != null) {
deepLinksRegistry.launch(intent)
}
}
private fun observeStateUpdates() {
@ -293,8 +299,6 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
val hasSavedWalletsProvider = { store.state.globalState.userWalletsListManager?.hasUserWallets == true }
intentProcessor.addHandler(BackgroundScanIntentHandler(hasSavedWalletsProvider, lifecycleScope))
intentProcessor.addHandler(WalletConnectLinkIntentHandler())
intentProcessor.addHandler(BuyCurrencyIntentHandler())
intentProcessor.addHandler(SellCurrencyIntentHandler())
}
private fun updateAppTheme(appThemeMode: AppThemeMode) {
@ -332,6 +336,10 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
lifecycleScope.launch {
intentProcessor.handleIntent(intent)
}
if (intent != null) {
deepLinksRegistry.launch(intent)
}
}
override fun showSnackbar(

View file

@ -2,6 +2,7 @@ package com.tangem.tap.common.redux.legacy
import com.tangem.domain.redux.LegacyAction
import com.tangem.tap.common.feedback.RateCanBeBetterEmail
import com.tangem.tap.common.feedback.SendTransactionFailedEmail
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.store
@ -20,6 +21,11 @@ internal object LegacyMiddleware {
GlobalAction.Onboarding.Start(action.scanResponse, canSkipBackup = action.canSkipBackup),
)
}
is LegacyAction.SendEmailTransactionFailed -> {
store.state.globalState.feedbackManager?.sendEmail(
SendTransactionFailedEmail(action.errorMessage),
)
}
}
next(action)
}

View file

@ -310,4 +310,22 @@ internal object TokensDomainModule {
): GetNetworksSupportedByWallet {
return GetNetworksSupportedByWallet(repository = repository)
}
@Provides
@ViewModelScoped
fun provideGetBalanceNotEnoughForFeeWarningUseCase(
currenciesRepository: CurrenciesRepository,
dispatchers: CoroutineDispatcherProvider,
): GetBalanceNotEnoughForFeeWarningUseCase {
return GetBalanceNotEnoughForFeeWarningUseCase(currenciesRepository, dispatchers)
}
@Provides
@ViewModelScoped
fun provideIsAmountSubtractAvailableUseCase(
currenciesRepository: CurrenciesRepository,
dispatchers: CoroutineDispatcherProvider,
): IsAmountSubtractAvailableUseCase {
return IsAmountSubtractAvailableUseCase(currenciesRepository, dispatchers)
}
}

View file

@ -7,7 +7,6 @@ import com.tangem.domain.transaction.usecase.CreateTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -20,24 +19,21 @@ internal object TransactionDomainModule {
@Provides
@ViewModelScoped
fun provideGetFeeUseCase(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade, dispatchers)
fun provideGetFeeUseCase(walletManagersFacade: WalletManagersFacade): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade)
}
@Provides
@ViewModelScoped
fun provideSendTransactionUseCase(
isDemoCardUseCase: IsDemoCardUseCase,
walletManagersFacade: WalletManagersFacade,
cardSdkConfigRepository: CardSdkConfigRepository,
transactionRepository: TransactionRepository,
): SendTransactionUseCase {
return SendTransactionUseCase(
isDemoCardUseCase = isDemoCardUseCase,
cardSdkConfigRepository = cardSdkConfigRepository,
walletManagersFacade = walletManagersFacade,
transactionRepository = transactionRepository,
)
}

View file

@ -1,27 +0,0 @@
package com.tangem.tap.features.intentHandler.handlers
import android.content.Intent
import com.tangem.tap.features.intentHandler.IntentHandler
/**
[REDACTED_AUTHOR]
*/
class BuyCurrencyIntentHandler : IntentHandler {
override fun handleIntent(intent: Intent?): Boolean {
// FIXME: [REDACTED_JIRA]
// val data = intent?.data ?: return false
// val currency = store.state.walletState.selectedCurrency ?: return false
//
// val successUri = Uri.parse(ExchangeUrlBuilder.SUCCESS_URL)
// return if (data.host == successUri.host && data.authority == successUri.authority) {
// val currencyType = AnalyticsParam.CurrencyType.Currency(currency)
// Analytics.send(TokenScreenAnalyticsEvent.Bought(currencyType.value))
// true
// } else {
// false
// }
return false
}
}

View file

@ -1,44 +0,0 @@
package com.tangem.tap.features.intentHandler.handlers
import android.content.Intent
import com.tangem.tap.features.intentHandler.IntentHandler
/**
[REDACTED_AUTHOR]
*/
class SellCurrencyIntentHandler : IntentHandler {
override fun handleIntent(intent: Intent?): Boolean {
// FIXME: [REDACTED_JIRA]
// return try {
// val intentData = intent?.data ?: return false
// val transactionID = intentData.getQueryParameter(TRANSACTION_ID_PARAM) ?: return false
// val currency = intentData.getQueryParameter(CURRENCY_CODE_PARAM) ?: return false
// val amount = intentData.getQueryParameter(CURRENCY_AMOUNT_PARAM) ?: return false
// val destinationAddress = intentData.getQueryParameter(DEPOSIT_WALLET_ADDRESS_PARAM) ?: return false
//
// Timber.d("MoonPay Sell: $amount $currency to $destinationAddress")
// store.dispatchOnMain(
// TradeCryptoAction.SendCrypto(
// currencyId = currency,
// amount = amount,
// destinationAddress = destinationAddress,
// transactionId = transactionID,
// ),
// )
// true
// } catch (exception: Exception) {
// Timber.d("Not MoonPay URL")
// false
// }
return false
}
// private companion object {
// private const val TRANSACTION_ID_PARAM = "transactionId"
// private const val CURRENCY_CODE_PARAM = "baseCurrencyCode"
// private const val CURRENCY_AMOUNT_PARAM = "baseCurrencyAmount"
// private const val DEPOSIT_WALLET_ADDRESS_PARAM = "depositWalletAddress"
// }
}

View file

@ -22,6 +22,7 @@ import com.tangem.tap.domain.TapError
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE
import com.tangem.tap.features.send.redux.PrepareSendScreen
import com.tangem.tap.features.send.redux.SendAction
import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager
import com.tangem.tap.network.exchangeServices.buyErc20TestnetTokens
import com.tangem.tap.proxy.redux.DaggerGraphState
@ -50,19 +51,18 @@ object TradeCryptoMiddleware {
if (DemoHelper.tryHandle(state, action)) return
when (action) {
is TradeCryptoAction.SendCrypto -> preconfigureAndOpenSendScreen()
is TradeCryptoAction.FinishSelling -> openReceiptUrl(action.transactionId)
is TradeCryptoAction.New.Buy -> proceedNewBuyAction(state, action)
is TradeCryptoAction.New.Sell -> proceedNewSellAction(action)
is TradeCryptoAction.New.Swap -> openSwap(
is TradeCryptoAction.Buy -> proceedBuyAction(state, action)
is TradeCryptoAction.Sell -> proceedSellAction(action)
is TradeCryptoAction.Swap -> openSwap(
currency = action.cryptoCurrency,
)
is TradeCryptoAction.New.SendToken -> handleNewSendToken(action = action)
is TradeCryptoAction.New.SendCoin -> handleNewSendCoin(action = action)
is TradeCryptoAction.SendToken -> handleSendToken(action = action)
is TradeCryptoAction.SendCoin -> handleSendCoin(action = action)
}
}
private fun proceedNewBuyAction(state: () -> AppState?, action: TradeCryptoAction.New.Buy) {
private fun proceedBuyAction(state: () -> AppState?, action: TradeCryptoAction.Buy) {
val networkAddress = action.cryptoCurrencyStatus.value.networkAddress
?.defaultAddress
?.let(NetworkAddress.Address::value)
@ -119,7 +119,7 @@ object TradeCryptoMiddleware {
}
}
private fun proceedNewSellAction(action: TradeCryptoAction.New.Sell) {
private fun proceedSellAction(action: TradeCryptoAction.Sell) {
val networkAddress = action.cryptoCurrencyStatus.value.networkAddress
?.defaultAddress
?.let(NetworkAddress.Address::value)
@ -139,33 +139,6 @@ object TradeCryptoMiddleware {
}
}
private fun preconfigureAndOpenSendScreen() = scope.launch {
// FIXME: [REDACTED_JIRA]
// val selectedWalletData = store.state.walletState.selectedWalletData ?: return
//
// Analytics.send(Token.ButtonSend(AnalyticsParam.CurrencyType.Currency(selectedWalletData.currency)))
// val walletManager = store.state.walletState.getWalletManager(selectedWalletData.currency).guard {
// FirebaseCrashlytics.getInstance().recordException(IllegalStateException("WalletManager is null"))
// return
// }
//
// store.dispatchOnMain(
// PrepareSendScreen(
// walletManager = walletManager,
// coinAmount = walletManager.wallet.amounts[AmountType.Coin],
// coinRate = selectedWalletData.fiatRate,
// ),
// )
// store.dispatchOnMain(
// SendAction.SendSpecificTransaction(
// sendAmount = action.amount,
// destinationAddress = action.destinationAddress,
// transactionId = action.transactionId,
// ),
// )
// store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.Send))
}
private fun openReceiptUrl(transactionId: String) {
store.dispatchOnMain(NavigationAction.PopBackTo())
store.state.globalState.exchangeManager.getSellCryptoReceiptUrl(
@ -182,7 +155,7 @@ object TradeCryptoMiddleware {
store.dispatchOnMain(NavigationAction.NavigateTo(screen = AppScreen.Swap, bundle = bundle))
}
private fun handleNewSendToken(action: TradeCryptoAction.New.SendToken) {
private fun handleSendToken(action: TradeCryptoAction.SendToken) {
val currency = action.tokenCurrency
val blockchain = Blockchain.fromId(currency.network.id.value)
@ -221,6 +194,17 @@ object TradeCryptoMiddleware {
),
)
val txInfo = action.transactionInfo
if (txInfo != null) {
store.dispatchOnMain(
SendAction.SendSpecificTransaction(
sendAmount = txInfo.amount,
destinationAddress = txInfo.destinationAddress,
transactionId = txInfo.transactionId,
),
)
}
val bundle = bundleOf(
SendRouter.CRYPTO_CURRENCY_KEY to currency,
SendRouter.USER_WALLET_ID_KEY to action.userWallet.walletId.stringValue,
@ -229,7 +213,7 @@ object TradeCryptoMiddleware {
}
}
private fun handleNewSendCoin(action: TradeCryptoAction.New.SendCoin) {
private fun handleSendCoin(action: TradeCryptoAction.SendCoin) {
val cryptoStatus = action.coinStatus
val currency = cryptoStatus.currency
val blockchain = Blockchain.fromId(currency.network.id.value)
@ -278,6 +262,17 @@ object TradeCryptoMiddleware {
is CryptoCurrency.Token -> error("Action.tokenStatus.currency is Token")
}
val txInfo = action.transactionInfo
if (txInfo != null) {
store.dispatchOnMain(
SendAction.SendSpecificTransaction(
sendAmount = txInfo.amount,
destinationAddress = txInfo.destinationAddress,
transactionId = txInfo.transactionId,
),
)
}
val bundle = bundleOf(
SendRouter.CRYPTO_CURRENCY_KEY to currency,
SendRouter.USER_WALLET_ID_KEY to action.userWallet.walletId.stringValue,

View file

@ -1,6 +1,5 @@
package com.tangem.tap.proxy.redux
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
@ -13,6 +12,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.features.managetokens.featuretoggles.ManageTokensFeatureToggles
import com.tangem.features.managetokens.navigation.ManageTokensRouter
import com.tangem.features.send.api.featuretoggles.SendFeatureToggles

View file

@ -23,6 +23,9 @@ data class ExpressErrorValue(
@Json(name = "minAmount")
val minAmount: String?,
@Json(name = "maxAmount")
val maxAmount: String?,
@Json(name = "decimals")
val decimals: Int?,

1
core/deep-links/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,23 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.core.deeplink"
}
dependencies {
/* Libs - AndroidX */
implementation(deps.lifecycle.runtime.ktx)
/* Libs - Other */
implementation(deps.timber)
/* DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

1
core/deep-links/global/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,15 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.core.deeplink.global"
}
dependencies {
/* Project */
implementation(projects.core.deepLinks)
}

View file

@ -0,0 +1,12 @@
package com.tangem.core.deeplink.global
import com.tangem.core.deeplink.DeepLink
class BuyCurrencyDeepLink(val onReceive: () -> Unit) : DeepLink {
override val uri: String = "tangem://success.tangem.com"
override fun onReceive(params: Map<String, String>) {
onReceive()
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.core.deeplink.global
import com.tangem.core.deeplink.DeepLink
class SellCurrencyDeepLink(val onReceive: (data: Data) -> Unit) : DeepLink {
override val uri: String = "tangem://sell-request.tangem.com"
override fun onReceive(params: Map<String, String>) {
val data = Data(
transactionId = params["transactionId"] ?: return,
baseCurrencyAmount = params["baseCurrencyAmount"] ?: return,
depositWalletAddress = params["depositWalletAddress"] ?: return,
)
onReceive(data)
}
data class Data(
val transactionId: String,
val baseCurrencyAmount: String,
val depositWalletAddress: String,
)
}

View file

@ -0,0 +1,36 @@
package com.tangem.core.deeplink
/**
* Represents a deep link.
*/
interface DeepLink {
/**
* ID of the deep link.
*
* By default, it is the same as the [uri].
* */
val id: String get() = uri
/**
* URI of the deep link.
*
* **Note: Remember to add the URI in the AndroidManifest.xml file in the `app` module.**
*
* Query parameters will be received automatically.
*
* Path parameters can be added using the following syntax:
* ```kotlin
* "tangem://link" // Without parameters
* "tangem://link/{param1}/{param2}" // With path parameters
* ```
* */
val uri: String
/**
* Method to be called when this deep link is received.
*
* @param params Map of parameters received from the deep link.
* */
fun onReceive(params: Map<String, String>)
}

View file

@ -0,0 +1,63 @@
package com.tangem.core.deeplink
import android.content.Intent
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
// TODO: Add tests
/**
* Provides functionality to handle deep links.
*
* Allows deep links to be launched, registered, or unregistered.
*/
interface DeepLinksRegistry {
/**
* Finds matches registered deep links for the given [intent] and launches them.
*
* @return `true` if any deep link was received, `false` otherwise.
*/
fun launch(intent: Intent): Boolean
/**
* Registers the given [deepLink].
*
* @see registerWithLifecycle
* @see registerWithViewModel
*/
fun register(deepLink: DeepLink)
/**
* Registers the given [deepLinks].
*
* @see registerWithLifecycle
* @see registerWithViewModel
*/
fun register(deepLinks: Collection<DeepLink>)
/**
* Unregisters the given [deepLinks].
*/
fun unregister(deepLinks: Collection<DeepLink>)
/**
* Unregisters the given [deepLink].
*/
fun unregister(deepLink: DeepLink)
/**
* Unregisters deep links with the given [ids].
* */
fun unregisterByIds(ids: Collection<String>)
/**
* Registers the [deepLinks] when the [owner] is resumed and ensures that they are unregistered when the [owner] is
* stopped.
*/
fun registerWithLifecycle(owner: LifecycleOwner, deepLinks: Collection<DeepLink>)
/**
* Registers the [deepLinks] and ensures that they are unregistered when the [ViewModel] is closed.
*/
fun registerWithViewModel(viewModel: ViewModel, deepLinks: Collection<DeepLink>)
}

View file

@ -0,0 +1,20 @@
package com.tangem.core.deeplink.di
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.deeplink.impl.DefaultDeepLinksRegistry
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object DeepLinksModule {
@Provides
@Singleton
fun provideDeepLinksRegistry(): DeepLinksRegistry {
return DefaultDeepLinksRegistry()
}
}

View file

@ -0,0 +1,173 @@
package com.tangem.core.deeplink.impl
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import com.tangem.core.deeplink.DeepLink
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.deeplink.utils.DeepLinksLifecycleObserver
import timber.log.Timber
internal class DefaultDeepLinksRegistry : DeepLinksRegistry {
private var registries: List<DeepLink> = emptyList()
override fun launch(intent: Intent): Boolean {
val received = intent.data ?: return false
var hasMatch = false
Timber.d(
"""
Received deep link intent
|- Received URI: $received
|- Registries: $registries
""".trimIndent(),
)
registries.forEach { deepLink ->
val expected = deepLink.uri.toUri()
if (!isMatches(expected, received)) return@forEach
hasMatch = true
val params = getParams(expected, received)
Timber.d(
"""
Matched deep link
|- Expected URI: $expected
|- Received URI: $received
|- Params: $params
""".trimIndent(),
)
deepLink.onReceive(params)
}
if (!hasMatch) {
Timber.d(
"""
No match found for deep link
|- Received URI: $received
|- Registries: $registries
""".trimIndent(),
)
}
return hasMatch
}
override fun register(deepLinks: Collection<DeepLink>) {
registries = (registries + deepLinks).distinctBy(DeepLink::id)
Timber.d(
"""
Registered deep links
|- Registries: $registries
""".trimIndent(),
)
}
override fun register(deepLink: DeepLink) {
registries = (registries + deepLink).distinctBy(DeepLink::id)
Timber.d(
"""
Registered deep link
|- Registries: $registries
""".trimIndent(),
)
}
override fun unregister(deepLinks: Collection<DeepLink>) {
registries = registries.filter { it !in deepLinks }
Timber.d(
"""
Unregistered deep links
|- Registries: $registries
""".trimIndent(),
)
}
override fun unregister(deepLink: DeepLink) {
registries = registries.filter { it.id != deepLink.id }
Timber.d(
"""
Unregistered deep link
|- Registries: $registries
""".trimIndent(),
)
}
override fun unregisterByIds(ids: Collection<String>) {
registries = registries.filter { it.id !in ids }
Timber.d(
"""
Unregistered deep links
|- Registries: $registries
""".trimIndent(),
)
}
override fun registerWithLifecycle(owner: LifecycleOwner, deepLinks: Collection<DeepLink>) {
val observer = DeepLinksLifecycleObserver(deepLinksRegistry = this, deepLinks)
owner.lifecycle.addObserver(observer)
}
override fun registerWithViewModel(viewModel: ViewModel, deepLinks: Collection<DeepLink>) {
viewModel.addCloseable {
unregister(deepLinks)
}
register(deepLinks)
}
private fun isMatches(received: Uri, expected: Uri): Boolean {
if (received == expected) return true
if (received.authority != expected.authority ||
received.pathSegments.size != expected.pathSegments.size
) {
return false
}
received.pathSegments.forEachIndexed { index, receivedSegment ->
val expectedSegment = expected.pathSegments[index]
if (receivedSegment != expectedSegment &&
!(receivedSegment.startsWith(prefix = "{") && receivedSegment.endsWith(suffix = "}"))
) {
return false
}
}
return true
}
private fun getParams(received: Uri, expected: Uri): Map<String, String> {
val params = mutableMapOf<String, String>()
received.pathSegments.forEachIndexed { index, receivedSegment ->
val expectedSegment = expected.pathSegments[index]
if (receivedSegment != expectedSegment &&
receivedSegment.startsWith(prefix = "{") &&
receivedSegment.endsWith(suffix = "}")
) {
val path = receivedSegment
.replace(oldValue = "{", newValue = "")
.replace(oldValue = "}", newValue = "")
params[path] = expectedSegment
}
}
expected.queryParameterNames.forEach { paramName ->
expected.getQueryParameter(paramName)?.let { param ->
params[paramName] = param
}
}
return params
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.core.deeplink.utils
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.tangem.core.deeplink.DeepLink
import com.tangem.core.deeplink.DeepLinksRegistry
internal class DeepLinksLifecycleObserver(
private val deepLinksRegistry: DeepLinksRegistry,
private val deepLinks: Collection<DeepLink>,
) : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
deepLinksRegistry.register(deepLinks)
}
override fun onPause(owner: LifecycleOwner) {
deepLinksRegistry.unregister(deepLinks)
}
}

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="common_fee_error">Erhalt der Gebühr fehlgeschlagen</string>
<string name="no_account_bnb">Senden Sie Geld an diese Andresse um ein Konto zu erstellen</string>
<string name="send_error_dust_amount_format">Minimaler Betrag ist %s</string>
<string name="send_error_dust_change">Restbestand zu klein</string>
<string name="send_error_invalid_fee_value">Falsche Gebühr</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="common_fee_error">Échec de réception des commissions</string>
<string name="no_account_bnb">Pour créer un compte, envoyez des fonds monétaires à cette adresse</string>
<string name="send_error_dust_amount_format">Le montant minimal est de %s</string>
<string name="send_error_dust_change">Le reste est trop petit</string>
<string name="send_error_invalid_fee_value">Commission non valide</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="common_fee_error">Impossibile ottenere la commissione</string>
<string name="no_account_bnb">Per creare un account, invia fondi a questo indirizzo</string>
<string name="send_error_dust_amount_format">L\'importo minimo è di %s</string>
<string name="send_error_dust_change">L\'importo residuo è molto basso</string>
<string name="send_error_invalid_fee_value">Commissione non valida</string>

View file

@ -6,8 +6,8 @@
<string name="common_utxo_validate_withdrawal_message_warning">Из-за ограничений %1$s в одну транзакцию может поместиться только %2$d UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму.</string>
<string name="eth_gas_required_exceeds_allowance">Недостаточно средств для совершения транзакции. Пожалуйста, пополните свой аккаунт.</string>
<string name="generic_error_code">Произошла ошибка. Код: %s.</string>
<string name="kaspa_withdrawal_message_warning">Из-за ограничений Kaspa в одну транзакцию может поместиться только %1$d UTXO. Это означает, что вы можете отправить только %2$s или меньше. Вам нужно уменьшить сумму.</string>
<string name="no_account_generic">Чтобы использовать сеть %1$s, вы должны оплатить резерв аккаунта (%2$s %3$s), который блокируется и не используется в вашем балансе.</string>
<string name="no_account_bnb">Для создания аккаунта отправьте средства на этот адрес</string>
<string name="no_account_generic">Чтобы использовать сеть %1$s, вы должны оплатить резерв аккаунта (%2$s %3$s), который блокируется и не используется в вашем балансе</string>
<string name="no_account_polkadot">Аккаунт получателя не активирован. Отправьте %s или более для активации аккаунта.</string>
<string name="send_error_dust_amount_format">Минимальная сумма: %s</string>
<string name="send_error_dust_change">Сдача слишком мала</string>

View file

@ -136,6 +136,7 @@
<string name="common_transfer">Перевод</string>
<string name="common_understand">Я понял</string>
<string name="common_unreachable">Недоступно</string>
<string name="common_unknown_error">Произошла ошибка. Пожалуйста, попробуйте снова.</string>
<string name="common_yes">Да</string>
<string name="contract_address_copied_message">Адрес контракта скопирован!</string>
<string name="currency_subtitle_expanded">Доступные сети</string>
@ -231,6 +232,7 @@
<string name="express_provider">Провайдер</string>
<string name="express_provider_best_rate">Лучший курс</string>
<string name="express_provider_min_amount">Доступно с %s</string>
<string name="express_provider_max_amount">Доступно до %s</string>
<string name="express_provider_not_available">Недоступно для этой пары</string>
<string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="express_terms_of_use">Условиями использования</string>
@ -448,8 +450,9 @@
<string name="send_amount_label">Сумма</string>
<string name="send_amount_substract">Вычесть из суммы отправки</string>
<string name="send_amount_substract_footer">Сумма к получению %s</string>
<string name="send_alert_button_request_support">Поддержка</string>
<string name="send_alert_transaction_failed_title">Транзакция не выполнена</string>
<string name="send_alert_transaction_failed_text">Причина: %1$s\Код:%2$s</string>
<string name="send_alert_transaction_failed_text">Причина: %1$s\nКод: %2$s</string>
<string name="send_date_format">%1$s в %2$s</string>
<string name="send_destination_hint_address">Адрес</string>
<string name="send_destination_tag_field">Код назначения</string>
@ -477,8 +480,10 @@
<string name="send_notification_exceed_balance_title">Недостаточно средств</string>
<string name="send_notification_exceed_fee_text">Размер комиссии превышает баланс сети. Для продолжения необходимо пополнить баланс сети.</string>
<string name="send_notification_exceed_fee_title">Комиссия превышает баланс</string>
<string name="send_notification_high_fee_text">Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01.</string>
<string name="send_notification_high_fee_title">Увеличение комиссии</string>
<string name="send_notification_high_fee_text">Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01.</string>
<string name="send_notification_fee_too_high_accept">Оставить %s XTZ</string>
<string name="send_notification_fee_too_high_ignore">Отправить все</string>
<string name="send_notification_fee_too_high_title">Установлена высокая комиссия</string>
<string name="send_notification_fee_too_high_text">Сумма комиссии в %s раз превышает рекомендованную. Убедитесь, что указанная комиссия верна.</string>
<string name="send_notification_invalid_amount_text">Включенная комиссия превышает сумму перевода, что приводит к отрицательному значению</string>
@ -528,7 +533,6 @@
<string name="swapping_approve_information_title">Подтвердить</string>
<string name="swapping_error_wrapper">Ошибка: %s</string>
<string name="swapping_from_title">Вы отправляете</string>
<string name="swapping_generic_error">Произошла ошибка. Пожалуйста, попробуйте еще раз.</string>
<string name="swapping_give_permission">Дать разрешение</string>
<string name="swapping_high_price_impact_description">Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму.</string>
<string name="swapping_insufficient_funds">Недостаточно средств</string>
@ -690,8 +694,9 @@
<string name="warning_express_not_enough_fee_for_token_tx_description">Чтобы совершить транзакцию, вам необходимо внести немного %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Невозможно покрыть комиссию %s</string>
<string name="warning_express_refresh_required_title">Cервис временно недоступен</string>
<string name="warning_express_too_minimal_amount_description">Пожалуйста, измените сумму для обмена</string>
<string name="warning_express_wrong_amount_description">Пожалуйста, измените сумму для обмена</string>
<string name="warning_express_too_minimal_amount_title">Сумма для обмена должна быть не менее %s</string>
<string name="warning_express_too_maximum_amount_title">Сумма для обмена должна быть не более %s</string>
<string name="warning_failed_to_verify_card_message">Возможно, данная карта - образец или подделка</string>
<string name="warning_failed_to_verify_card_title">Ошибка проверки подлинности</string>
<string name="warning_low_signatures_message">На этой карте осталось всего %s подписей. Вам следует вывести все ваши средства.</string>

View file

@ -4,6 +4,7 @@
<string name="address_type_legacy">遺留資產</string>
<string name="common_fee_error">獲取費用失敗</string>
<string name="kaspa_withdrawal_message_warning">由於 Kaspa 的限制,只有%1$d UTXO 可以放入單次交易中。這意味著您只能發送%2$s或更少數量。您需要減少數量。</string>
<string name="no_account_bnb">要創建帳戶,請將資金發送到此地址</string>
<string name="no_account_polkadot">目標帳戶未激活。發送 %s 或更多以激活帳戶</string>
<string name="send_error_dust_amount_format">最小數量是 %s</string>
<string name="send_error_dust_change">更動太小</string>

View file

@ -327,7 +327,6 @@
<string name="swapping_approve_information_text">批准被視為所有去中心化交易所的行業標準,並保護您的錢包在未經您許可的情況下不被智能合約訪問。按照設計,智能合約無法訪問您的代幣,除非您從您的終端批准訪問。通過“解鎖”您的代幣,您將獲得 1inch 智能合約使用您的資產的權限。網絡的礦工將獲得Gas Fee由您支付作為補償以在區塊鏈上記錄此操作。一旦獲得許可您就可以交易您的代幣。</string>
<string name="swapping_approve_information_title">批准</string>
<string name="swapping_error_wrapper">錯誤: %s</string>
<string name="swapping_generic_error">有錯誤。請再試一遍</string>
<string name="swapping_give_permission">賦予權限</string>
<string name="swapping_high_price_impact_description">在此代幣交換的數量將對價格產生重大影響,並降低您收到的數量</string>
<string name="swapping_insufficient_funds">餘額不足</string>

View file

@ -7,7 +7,8 @@
<string name="eth_gas_required_exceeds_allowance">Not enough funds for the transaction. Please top up your account.</string>
<string name="generic_error_code">An error occurred. Code: %s.</string>
<string name="kaspa_withdrawal_message_warning">Due to Kaspa limitations only %1$d UTXOs can fit in a single transaction. This means you can only send %2$s or less. You need to reduce the amount.</string>
<string name="no_account_generic">To use the %1$s network, you must pay the account reserve (%2$s %3$s), which locks up and hides that amount indefinitely.</string>
<string name="no_account_bnb">To create account send funds to this address</string>
<string name="no_account_generic">To use the %1$s network, you must pay the account reserve (%2$s %3$s), which locks up and hides that amount indefinitely</string>
<string name="no_account_polkadot">Destination account is not active. Send %s or more to activate the account.</string>
<string name="send_error_dust_amount_format">Minimum amount is %s</string>
<string name="send_error_dust_change">Change is too small</string>

View file

@ -135,6 +135,7 @@
<string name="common_transfer">Transfer</string>
<string name="common_understand">I understand</string>
<string name="common_unreachable">Unreachable</string>
<string name="common_unknown_error">There was an error. Please try again.</string>
<string name="common_yes">Yes</string>
<string name="contract_address_copied_message">Contract address copied!</string>
<string name="currency_subtitle_expanded">Available networks</string>
@ -234,6 +235,7 @@
<string name="express_provider">Provider</string>
<string name="express_provider_best_rate">Best rate</string>
<string name="express_provider_min_amount">Available from %s</string>
<string name="express_provider_max_amount">Available up to %s</string>
<string name="express_provider_not_available">Unavailable for this pair</string>
<string name="express_provider_permission_needed">Permission Required</string>
<string name="express_terms_of_use">Terms of Use</string>
@ -447,8 +449,9 @@
<string name="send_amount_label">Amount</string>
<string name="send_amount_substract">Subtract from send amount</string>
<string name="send_amount_substract_footer">The recipient will receive %s</string>
<string name="send_alert_button_request_support">Support</string>
<string name="send_alert_transaction_failed_title">The transaction is not completed</string>
<string name="send_alert_transaction_failed_text">Reason: %1$s\nCode:%2$s</string>
<string name="send_alert_transaction_failed_text">Reason: %1$s\nCode: %2$s</string>
<string name="send_confirm_label">Confirm</string>
<string name="send_date_format">%1$s at %2$s</string>
<string name="send_destination_hint_address">Address</string>
@ -482,8 +485,10 @@
<string name="send_notification_exceed_balance_title">Total exceeds balance</string>
<string name="send_notification_exceed_fee_text">The commission fee exceeds the network balance. To continue, it is necessary to replenish the network balance.</string>
<string name="send_notification_exceed_fee_title">Fee exceeds balance</string>
<string name="send_notification_high_fee_text">The fee for transferring the entire balance is higher. To reduce the commission, you can leave 0.01.</string>
<string name="send_notification_high_fee_title">Fee is increased</string>
<string name="send_notification_high_fee_text">The fee for transferring the entire balance is higher. To reduce the commission, you can leave 0.01.</string>
<string name="send_notification_fee_too_high_accept">Reduce by %s XTZ</string>
<string name="send_notification_fee_too_high_ignore">No, send all</string>
<string name="send_notification_fee_too_high_title">Custom fee is high</string>
<string name="send_notification_fee_too_high_text">The commission amount is %s times the recommended amount. Make sure that the custom settings are correct.</string>
<string name="send_notification_invalid_amount_text">The included commission exceeds the transfer amount, leading to a negative value</string>
@ -519,6 +524,7 @@
<string name="send_transaction_success">Transaction has been successfully signed and sent to the blockchain node. Wallet balance will be updated in a while</string>
<string name="send_validation_invalid_address">Invalid address</string>
<string name="sent_transaction_sent_title">Transaction sent</string>
<string name="send_wallet_balance_format">%s (%s)</string>
<string name="shop_buy_now">Buy now</string>
<string name="shop_i_have_a_promo_code">I have a promo code…</string>
<string name="shop_one_wallet">Tangem Wallet</string>
@ -545,7 +551,6 @@
<string name="swapping_approve_information_title">Approve</string>
<string name="swapping_error_wrapper">Error: %s</string>
<string name="swapping_from_title">You swap</string>
<string name="swapping_generic_error">There was an error. Please try again.</string>
<string name="swapping_give_permission">Give Permission</string>
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_insufficient_funds">Insufficient funds</string>
@ -705,8 +710,9 @@
<string name="warning_express_not_enough_fee_for_token_tx_description">To make a transaction you need to deposit some %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Unable to cover %s fee</string>
<string name="warning_express_refresh_required_title">Service temporarily unavailable</string>
<string name="warning_express_too_minimal_amount_description">Please change the amount to swap</string>
<string name="warning_express_wrong_amount_description">Please change the amount to swap</string>
<string name="warning_express_too_minimal_amount_title">The amount to swap must be at least %s</string>
<string name="warning_express_too_maximum_amount_title">The amount of tokens to be swapped must not exceed %s</string>
<string name="warning_failed_to_verify_card_message">This card might be a production sample or counterfeit</string>
<string name="warning_failed_to_verify_card_title">Authenticity check failed</string>
<string name="warning_low_signatures_message">Only %s signatures are left on this card. You must withdraw all of your funds.</string>

View file

@ -0,0 +1,172 @@
package com.tangem.core.ui.components.fields
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.TopCenter
import androidx.compose.ui.Alignment.Companion.TopStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.*
import java.text.DecimalFormat
/**
* Simple text field for amount input.
* Validates and trims input text using [DecimalFormat]. Formats visual output using [AmountVisualTransformation].
* Can display aligned placeholder and currency symbol [symbol].
*
* @param value initial text
* @param decimals number of decimal places
* @param onValueChange callback
* @param textStyle text and placeholder styles
* @param modifier modifier
* @param symbol currency symbol
* @param color text color
* @param placeholderAlignment alignment of placeholder
* @param showPlaceholder show placeholder
* @param keyboardOptions keyboard options
*
* @see [SimpleTextField] for standard text field
*/
@Composable
fun AmountTextField(
value: String,
decimals: Int,
onValueChange: (String) -> Unit,
textStyle: TextStyle,
modifier: Modifier = Modifier,
symbol: String? = null,
color: Color = TangemTheme.colors.text.primary1,
placeholderAlignment: Alignment = TopStart,
showPlaceholder: Boolean = true,
keyboardOptions: KeyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
) {
val decimalFormat = rememberDecimalFormat()
val placeholderTextAlign = if (placeholderAlignment == TopCenter) {
TextAlign.Center
} else {
TextAlign.Start
}
SimpleTextField(
value = value,
onValueChange = { newText ->
if (decimalFormat.isValidSymbols(newText)) {
val trimmed = decimalFormat.getValidatedNumberWithFixedDecimals(newText, decimals)
onValueChange(trimmed)
}
},
modifier = modifier
.background(TangemTheme.colors.background.action),
textStyle = textStyle,
color = color,
keyboardOptions = keyboardOptions,
singleLine = true,
visualTransformation = AmountVisualTransformation(decimals, symbol, decimalFormat),
decorationBox = { innerTextField ->
Box {
if (value.isBlank() && showPlaceholder) {
val placeholder = if (symbol != null) {
decimalFormat.defaultFormat().plus(" $symbol")
} else {
decimalFormat.defaultFormat()
}
Text(
text = placeholder,
style = textStyle,
color = TangemTheme.colors.text.disabled,
textAlign = placeholderTextAlign,
modifier = Modifier
.align(placeholderAlignment),
)
}
innerTextField()
}
},
)
}
private fun DecimalFormat.isValidSymbols(text: String): Boolean {
return checkDecimalSeparatorDuplicate(text) && checkGroupingSeparator(text)
}
// region preview
@Preview(locale = "en", showBackground = true, name = "English")
@Preview(locale = "ru", showBackground = true, name = "Russian")
@Composable
private fun AmountTextFieldPreview(
@PreviewParameter(AmountTextFieldPreviewProvider::class) amount: AmountTextFieldPreviewData,
) {
var text by remember { mutableStateOf(amount.value.orEmpty()) }
TangemTheme {
AmountTextField(
value = text,
decimals = amount.decimals,
symbol = amount.symbol,
placeholderAlignment = amount.placeholderAlignment,
showPlaceholder = amount.showPlaceholder,
onValueChange = { text = it },
textStyle = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth(),
)
}
}
private class AmountTextFieldPreviewProvider : PreviewParameterProvider<AmountTextFieldPreviewData> {
override val values = sequenceOf(
AmountTextFieldPreviewData(
symbol = "USD",
value = "1000000,123123",
decimals = 3,
placeholderAlignment = TopStart,
showPlaceholder = true,
),
AmountTextFieldPreviewData(
symbol = null,
value = "1000000.123123",
decimals = 6,
placeholderAlignment = TopStart,
showPlaceholder = false,
),
AmountTextFieldPreviewData(
symbol = "$",
value = null,
decimals = 2,
showPlaceholder = true,
placeholderAlignment = TopCenter,
),
AmountTextFieldPreviewData(
symbol = null,
value = null,
decimals = 2,
showPlaceholder = true,
placeholderAlignment = TopStart,
),
)
}
private data class AmountTextFieldPreviewData(
val symbol: String? = "$",
val value: String? = null,
val decimals: Int = 2,
val showPlaceholder: Boolean,
val placeholderAlignment: Alignment,
)
// endregion

View file

@ -3,14 +3,18 @@ package com.tangem.core.ui.components.fields
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@ -19,6 +23,7 @@ import com.tangem.core.ui.res.TangemTheme
/**
* Simple text field with placeholder
*/
@Suppress("ReusedModifierInstance")
@Composable
fun SimpleTextField(
value: String,
@ -29,32 +34,72 @@ fun SimpleTextField(
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
color: Color = TangemTheme.colors.text.primary1,
textStyle: TextStyle = TangemTheme.typography.body2.copy(color = color),
readOnly: Boolean = false,
decorationBox: (@Composable (innerTextField: @Composable () -> Unit) -> Unit)? = null,
) {
val focusRequester = remember { FocusRequester() }
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TangemTheme.typography.body2.copy(color = color),
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),
singleLine = singleLine,
readOnly = readOnly,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
decorationBox = { textValue ->
Box {
if (value.isBlank() && placeholder != null) {
Text(
text = placeholder.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.disabled,
modifier = Modifier,
)
}
textValue()
}
},
modifier = modifier
.focusRequester(focusRequester),
var textFieldValueState by remember {
mutableStateOf(
TextFieldValue(
text = value,
selection = when {
value.isEmpty() -> TextRange.Zero
else -> TextRange(value.length, value.length)
},
),
)
}
val focusRequester = remember { FocusRequester.Default }
val customTextSelectionColors = TextSelectionColors(
handleColor = TangemTheme.colors.text.secondary,
backgroundColor = TangemTheme.colors.text.secondary.copy(alpha = 0.4f),
)
val textFieldValue = textFieldValueState.copy(text = value)
SideEffect {
if (textFieldValue.selection != textFieldValueState.selection ||
textFieldValue.composition != textFieldValueState.composition
) {
textFieldValueState = textFieldValue
}
}
var lastTextValue by remember(value) { mutableStateOf(value) }
CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) {
BasicTextField(
value = textFieldValue,
onValueChange = { newTextFieldValueState ->
textFieldValueState = newTextFieldValueState
val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text
lastTextValue = newTextFieldValueState.text
if (stringChangedSinceLastInvocation) {
onValueChange(newTextFieldValueState.text)
}
},
textStyle = textStyle.copy(color = color),
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),
singleLine = singleLine,
readOnly = readOnly,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
decorationBox = decorationBox ?: { textValue ->
Box {
if (value.isBlank() && placeholder != null) {
Text(
text = placeholder.resolveReference(),
style = textStyle,
color = TangemTheme.colors.text.disabled,
)
}
textValue()
}
},
modifier = modifier
.focusRequester(focusRequester),
)
}
}

View file

@ -5,28 +5,49 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import com.tangem.core.ui.utils.formatWithThousands
import java.text.DecimalFormat
class AmountVisualTransformation(
private val symbol: String,
private val decimals: Int,
private val symbol: String? = null,
private val decimalFormat: DecimalFormat = DecimalFormat(),
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
buildAnnotatedString {
append(text)
if (text.isNotBlank()) {
append(" ")
append(symbol)
}
},
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return text.length
}
override fun transformedToOriginal(offset: Int): Int {
return text.length
override fun filter(text: AnnotatedString): TransformedText {
val formattedText = decimalFormat.formatWithThousands(
text.text,
decimals,
)
val groupingSymbol = decimalFormat.decimalFormatSymbols.groupingSeparator
return TransformedText(
text = buildAnnotatedString {
append(formattedText)
if (formattedText.isNotEmpty() && symbol != null) {
append(" $symbol")
}
},
offsetMapping = OffsetMappingImpl(text.text, formattedText, groupingSymbol),
)
}
private class OffsetMappingImpl(
private val text: String,
private val formattedText: String,
private val gropingSymbol: Char,
) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
var noneDigitCount = 0
var i = 0
while (i < offset + noneDigitCount) {
if (formattedText.getOrNull(i++) == gropingSymbol) noneDigitCount++
}
return (offset + noneDigitCount).coerceIn(0, formattedText.length)
}
override fun transformedToOriginal(offset: Int): Int {
val noneDigitCount = formattedText.take(offset).count { it == gropingSymbol }
return (offset - noneDigitCount).coerceIn(0, text.length)
}
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.core.ui.components.inputrow
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
/**
* Input row for entering amount. Manages correct amount format and validation
*
* @param title title reference
* @param text primary text reference
* @param onValueChange text change callback
* @param modifier modifier
* @param titleColor title color
* @param textColor text color
* @param keyboardOptions keyboard options for field
* @param iconRes action icon
* @param iconTint action icon tint
* @param onIconClick click on action icon
* @param showDivider show divider
*
* @see [InputRowDefault] for read only version
* @see <a href=https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-799&mode=design&
* t=IQ5lBJEkFGU4WSvi-4>InputRowEnter</a>
*/
@Composable
fun InputRowEnterAmount(
title: TextReference,
text: String,
decimals: Int,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
symbol: String? = null,
titleColor: Color = TangemTheme.colors.text.secondary,
textColor: Color = TangemTheme.colors.text.primary1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
iconRes: Int? = null,
iconTint: Color = TangemTheme.colors.icon.informative,
onIconClick: (() -> Unit)? = null,
showDivider: Boolean = false,
) {
DividerContainer(
modifier = modifier,
showDivider = showDivider,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing12),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography.caption2,
color = titleColor,
)
AmountTextField(
value = text,
decimals = decimals,
symbol = symbol,
onValueChange = onValueChange,
color = textColor,
textStyle = TangemTheme.typography.body2,
keyboardOptions = keyboardOptions,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8),
)
}
iconRes?.let {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
tint = iconTint,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing10,
bottom = TangemTheme.dimens.spacing10,
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
) { onIconClick?.invoke() },
)
}
}
}
}

View file

@ -0,0 +1,94 @@
package com.tangem.core.ui.components.inputrow
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
/**
* `Input Row Enter Info` for entering amount. Manages correct amount format and validation
* @param title title reference
* @param text primary text reference
* @param onValueChange text change callback
* @param modifier modifier
* @param titleColor title color
* @param textColor text color
* @param isSingleLine text
* @param visualTransformation applied transformation to text
* @param keyboardOptions keyboard options for field
* @param showDivider show divider
*
* @see [InputRowEnterInfo]
* @see <a href=https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-799&mode
* =design&t=IQ5lBJEkFGU4WSvi-4>Input Row Enter</a>
* @see <a href=https://www.figma.com/file/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?type=design&node
* * -id=7854-33577&mode=design&t=6o23sqF8fDQdn4C5-4>Input Row Enter Info</a>
*/
@Composable
fun InputRowEnterInfoAmount(
title: TextReference,
text: String,
decimals: Int,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
symbol: String? = null,
info: TextReference? = null,
titleColor: Color = TangemTheme.colors.text.secondary,
textColor: Color = TangemTheme.colors.text.primary1,
infoColor: Color = TangemTheme.colors.text.tertiary,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
showDivider: Boolean = false,
) {
DividerContainer(
modifier = modifier,
showDivider = showDivider,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing12),
) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography.caption2,
color = titleColor,
)
Row {
AmountTextField(
value = text,
decimals = decimals,
symbol = symbol,
onValueChange = onValueChange,
color = textColor,
textStyle = TangemTheme.typography.body2,
keyboardOptions = keyboardOptions,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8)
.weight(1f),
)
info?.let {
Text(
text = it.resolveReference(),
style = TangemTheme.typography.body2,
color = infoColor,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing8)
.align(Alignment.Bottom),
)
}
}
}
}
}

View file

@ -48,6 +48,7 @@ fun getActiveIconRes(blockchainId: String): Int {
"decimal", "decimal/test" -> R.drawable.img_decimal_22
"xdc", "xdc/test" -> R.drawable.img_xdc_22
"vechain", "vechain/test" -> R.drawable.img_vechain_22
"aptos", "aptos/test" -> R.drawable.img_aptos_22
else -> R.drawable.ic_alert_24
}
}
@ -97,6 +98,7 @@ fun getActiveIconResByNetworkId(networkId: String): Int {
"decimal", "decimal/test" -> R.drawable.img_decimal_22
"xdc-network", "xdc-network/test" -> R.drawable.img_xdc_22
"vechain", "vechain/test" -> R.drawable.img_vechain_22
"aptos", "aptos/test" -> R.drawable.img_aptos_22
else -> R.drawable.ic_alert_24
}
}
@ -143,6 +145,7 @@ fun getActiveIconResByCoinId(coinId: String): Int {
"decimal" -> R.drawable.img_decimal_22
"xdce-crowd-sale" -> R.drawable.img_xdc_22
"vechain" -> R.drawable.img_vechain_22
"aptos" -> R.drawable.img_aptos_22
else -> R.drawable.ic_alert_24
}
}
@ -192,6 +195,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int {
"decimal", "decimal/test" -> R.drawable.ic_decimal_22
"xdc", "xdc/test" -> R.drawable.ic_xdc_22
"vechain", "vechain/test" -> R.drawable.ic_vechain_22
"aptos", "aptos/test" -> R.drawable.ic_aptos_22
else -> R.drawable.ic_alert_24
}
}
@ -241,6 +245,7 @@ fun getGreyedOutIconResByNetworkId(networkId: String): Int {
"decimal", "decimal/test" -> R.drawable.ic_decimal_22
"xdc-network", "xdc-network/test" -> R.drawable.ic_xdc_22
"vechain", "vechain/test" -> R.drawable.ic_vechain_22
"aptos", "aptos/test" -> R.drawable.ic_aptos_22
else -> R.drawable.ic_alert_24
}
}

View file

@ -0,0 +1,153 @@
package com.tangem.core.ui.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
private const val TEXT_CHUNK_THOUSAND = 3
private const val POINT_SEPARATOR = '.'
@Composable
fun rememberDecimalFormat(): DecimalFormat {
val locale = LocalConfiguration.current.locale
val decimalSymbols = remember { DecimalFormatSymbols.getInstance(locale) }
return remember {
DecimalFormat().apply {
decimalFormatSymbols = decimalSymbols
isParseBigDecimal = true
}
}
}
/**
* Formats input [String] for InputField, to remove wrong symbols, letters etc
* Use [decimals] for cut this number symbols after floating point
*
* Example (with 8 decimals):
* input string - ab123.46377372ab53
* result string 123.46377372
*/
fun DecimalFormat.getValidatedNumberWithFixedDecimals(text: String, decimals: Int): String {
val thousandsSeparator = decimalFormatSymbols.groupingSeparator
val decimalSeparator = decimalFormatSymbols.decimalSeparator
val lastChar = text.lastOrNull()
val trimmedText = if (text.isNotEmpty() && (lastChar == thousandsSeparator || lastChar == POINT_SEPARATOR)) {
text.dropLast(1) + decimalSeparator
} else {
text
}
if (trimmedText.startsWith("0") && trimmedText.length > 1 && trimmedText[1] != decimalSeparator) {
return "0"
}
val filteredChars = trimmedText.replace(thousandsSeparator.toString(), "").filterIndexed { index, c ->
val isOneOrZeroPoint =
c == decimalSeparator && index != 0 && trimmedText.count { it == decimalSeparator } <= 1
val isIndexPointIndex =
c == decimalSeparator && index != 0 && trimmedText.indexOf(decimalSeparator) == index
c.isDigit() || isIndexPointIndex || isOneOrZeroPoint
}
// If dot is present, take first digits before decimal and first decimals digits after decimal
return if (filteredChars.count { it == decimalSeparator } == 1) {
val beforeDecimal = filteredChars.substringBefore(decimalSeparator)
val afterDecimal = filteredChars.substringAfter(decimalSeparator)
beforeDecimal + decimalSeparator + afterDecimal.take(decimals)
}
// If there is no dot, just take all digits
else {
filteredChars
}
}
/**
* Formats input [text] with grouping and decimal separators.
* Takes into account [decimals] number of digits after floating point.
*/
fun DecimalFormat.formatWithThousands(text: String, decimals: Int): String {
val thousandsSeparator = decimalFormatSymbols.groupingSeparator
val decimalSeparator = decimalFormatSymbols.decimalSeparator
val localizedText = text.replace("[,.]".toRegex(), decimalSeparator.toString())
return if (localizedText.count { it == decimalSeparator } == 1) {
val beforeDecimal = localizedText.substringBefore(decimalSeparator)
.reversed()
.chunked(TEXT_CHUNK_THOUSAND)
.joinToString(thousandsSeparator.toString())
.reversed()
val afterDecimal = localizedText.substringAfter(decimalSeparator)
beforeDecimal + decimalSeparator + afterDecimal.take(decimals)
}
// If there is no dot, just take all digits
else {
localizedText.reversed()
.chunked(TEXT_CHUNK_THOUSAND)
.joinToString(thousandsSeparator.toString())
.reversed()
}
}
fun DecimalFormat.defaultFormat(): String {
return "0${decimalFormatSymbols.decimalSeparator}00"
}
/**
* Checks if text input contains extra decimal separators.
* If so, it will return false, otherwise true.
*
* Note: number can contain only one decimal separator.
*/
fun DecimalFormat.checkDecimalSeparatorDuplicate(text: String): Boolean {
val regex = "[${decimalFormatSymbols.decimalSeparator}]".toRegex()
val decimalSeparatorCount = regex.findAll(text).count()
return decimalSeparatorCount <= 1 // only one decimal separator
}
/**
* Checks if text input contains grouping separators.
* If so, it will return false, otherwise true.
*
* Note: grouping separators are used only for VisualTransformations.
*/
fun DecimalFormat.checkGroupingSeparator(text: String): Boolean {
val regex = "[${decimalFormatSymbols.groupingSeparator}]".toRegex()
val decimalSeparatorCount = regex.findAll(text).count()
return decimalSeparatorCount == 0 // no grouping separator
}
fun String.parseToBigDecimal(decimals: Int): BigDecimal {
val decimalFormat = DecimalFormat().apply {
decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault())
isParseBigDecimal = true
maximumFractionDigits = decimals
minimumFractionDigits = decimals
}
return try {
decimalFormat.parse(this) as? BigDecimal ?: BigDecimal.ZERO
} catch (e: Exception) {
BigDecimal.ZERO
}
}
fun BigDecimal.parseBigDecimal(decimals: Int, roundingMode: RoundingMode = RoundingMode.DOWN): String {
val decimalFormat = DecimalFormat().apply {
decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault())
isParseBigDecimal = true
isGroupingUsed = false
maximumFractionDigits = decimals
minimumFractionDigits = 0
this.roundingMode = roundingMode
}
return try {
decimalFormat.format(this)
} catch (e: Exception) {
""
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.core.ui.utils
import java.text.DecimalFormat
@Deprecated("Deprecated due to unnecessary abstraction. Use methods from DecimalFormatterExt")
class InputNumberFormatter(
numberFormat: DecimalFormat,
) {

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M12.624,7.396C12.72,7.396 12.812,7.357 12.879,7.288L13.39,6.757C13.453,6.691 13.542,6.653 13.634,6.653H13.655C13.752,6.653 13.844,6.694 13.908,6.767L14.338,7.251C14.42,7.343 14.538,7.396 14.662,7.396H15.815C14.717,5.941 12.969,5 11,5C9.032,5 7.285,5.941 6.186,7.396H12.624ZM13.232,9.014H14.294V9.015H16.684C16.849,9.485 16.957,9.981 17,10.496H12.523C12.399,10.496 12.282,10.443 12.2,10.351L11.769,9.866C11.705,9.794 11.613,9.753 11.516,9.753H11.495C11.403,9.753 11.315,9.79 11.251,9.857L10.74,10.387C10.674,10.457 10.582,10.496 10.485,10.496H5C5.043,9.981 5.151,9.485 5.316,9.015H11.126C11.308,9.015 11.482,8.937 11.603,8.801L11.972,8.385C12.036,8.312 12.129,8.271 12.225,8.271C12.322,8.271 12.415,8.313 12.479,8.385L12.909,8.869C12.991,8.961 13.109,9.014 13.232,9.014ZM8.575,13.5C8.508,13.57 8.416,13.609 8.32,13.609V13.609H5.576C5.349,13.142 5.182,12.64 5.083,12.114H8.988C9.171,12.114 9.344,12.036 9.465,11.9L9.835,11.484C9.899,11.411 9.991,11.37 10.088,11.37C10.184,11.37 10.277,11.412 10.341,11.484L10.771,11.968C10.853,12.06 10.971,12.113 11.094,12.113H16.918C16.819,12.639 16.652,13.141 16.425,13.609H10.358C10.234,13.609 10.117,13.556 10.034,13.464L9.604,12.98C9.54,12.907 9.448,12.866 9.351,12.866H9.33C9.238,12.866 9.149,12.903 9.086,12.97L8.575,13.5ZM8.728,15.092H10.306V15.092H15.403C14.303,16.266 12.738,17 11,17C9.262,17 7.697,16.266 6.597,15.092H6.622C6.805,15.092 6.978,15.014 7.099,14.878L7.468,14.462C7.532,14.39 7.625,14.349 7.722,14.349C7.818,14.349 7.911,14.39 7.975,14.462L8.405,14.947C8.487,15.039 8.605,15.092 8.728,15.092Z"
android:fillColor="#000000"
android:fillType="evenOdd" />
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<group>
<clip-path android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z" />
<path
android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z"
android:fillColor="#000000" />
<path
android:pathData="M12.624,7.396C12.72,7.396 12.812,7.357 12.879,7.288L13.39,6.757C13.453,6.691 13.542,6.653 13.634,6.653H13.655C13.752,6.653 13.844,6.694 13.908,6.767L14.338,7.251C14.42,7.343 14.538,7.396 14.662,7.396H15.815C14.717,5.941 12.969,5 11,5C9.032,5 7.285,5.941 6.186,7.396H12.624ZM13.232,9.014H14.294V9.015H16.684C16.849,9.485 16.957,9.981 17,10.496H12.523C12.399,10.496 12.282,10.443 12.2,10.351L11.769,9.866C11.705,9.794 11.613,9.753 11.516,9.753H11.495C11.403,9.753 11.315,9.79 11.251,9.857L10.74,10.387C10.674,10.457 10.582,10.496 10.485,10.496H5C5.043,9.981 5.151,9.485 5.316,9.015H11.126C11.308,9.015 11.482,8.937 11.603,8.801L11.972,8.385C12.036,8.312 12.129,8.271 12.225,8.271C12.322,8.271 12.415,8.313 12.479,8.385L12.909,8.869C12.991,8.961 13.109,9.014 13.232,9.014ZM8.575,13.5C8.508,13.57 8.416,13.609 8.32,13.609V13.609H5.576C5.349,13.142 5.182,12.64 5.083,12.114H8.988C9.171,12.114 9.344,12.036 9.465,11.9L9.835,11.484C9.899,11.411 9.991,11.37 10.088,11.37C10.184,11.37 10.277,11.412 10.341,11.484L10.771,11.968C10.853,12.06 10.971,12.113 11.094,12.113H16.918C16.819,12.639 16.652,13.141 16.425,13.609H10.358C10.234,13.609 10.117,13.556 10.034,13.464L9.604,12.98C9.54,12.907 9.448,12.866 9.351,12.866H9.33C9.238,12.866 9.149,12.903 9.086,12.97L8.575,13.5ZM8.728,15.092H10.306V15.092H15.403C14.303,16.266 12.738,17 11,17C9.262,17 7.697,16.266 6.597,15.092H6.622C6.805,15.092 6.978,15.014 7.099,14.878L7.468,14.462C7.532,14.39 7.625,14.349 7.722,14.349C7.818,14.349 7.911,14.39 7.975,14.462L8.405,14.947C8.487,15.039 8.605,15.092 8.728,15.092Z"
android:fillColor="#ffffff"
android:fillType="evenOdd" />
</group>
</vector>

View file

@ -10,12 +10,16 @@ class PeriodicTask<T>(
private val task: suspend () -> Result<T>,
private val onSuccess: (T) -> Unit,
private val onError: (Throwable) -> Unit,
private val isDelayFirst: Boolean = false,
) {
private var isActive: AtomicBoolean = AtomicBoolean(false)
suspend fun runTaskWithDelay() {
isActive.set(true)
if (isDelayFirst) {
delay(delay)
}
while (isActive.get()) {
task.invoke()
.onSuccess {

View file

@ -1,5 +1,6 @@
package com.tangem.data.tokens.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.tokens.utils.CardCryptoCurrenciesFactory
import com.tangem.data.tokens.utils.NetworkStatusFactory
@ -7,6 +8,7 @@ import com.tangem.data.tokens.utils.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.network.NetworksStatusesStore
import com.tangem.datasource.local.token.UserTokensStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.tokens.model.CryptoCurrency
@ -68,6 +70,11 @@ internal class DefaultNetworksRepository(
networksStatusesStore.getSyncOrNull(userWalletId).orEmpty()
}
override fun isNeedToCreateAccountWithoutReserve(network: Network): Boolean {
val blockchain = Blockchain.fromNetworkId(network.id.value)
return blockchain == Blockchain.Aptos
}
private suspend fun fetchNetworksStatusesIfCacheExpired(
userWalletId: UserWalletId,
networks: Set<Network>,

View file

@ -7,10 +7,7 @@ import com.tangem.blockchain.blockchains.stellar.StellarMemo
import com.tangem.blockchain.blockchains.stellar.StellarTransactionExtras
import com.tangem.blockchain.blockchains.ton.TonTransactionExtras
import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.transaction.TransactionRepository
@ -44,6 +41,21 @@ internal class DefaultTransactionRepository(
)
}
override suspend fun sendTransaction(
txData: TransactionData,
signer: CommonSigner,
userWalletId: UserWalletId,
network: Network,
) = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
(walletManager as TransactionSender).send(txData, signer)
}
private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? {
val blockchain = Blockchain.fromId(networkId)
if (memo == null) return null

View file

@ -24,6 +24,12 @@ interface CardTypesResolver {
fun isBadWallet(): Boolean
fun isJrWallet(): Boolean
fun isGrimWallet(): Boolean
fun isSatoshiFriendsWallet(): Boolean
fun isWhiteWallet(): Boolean
fun isWallet2(): Boolean

View file

@ -42,6 +42,12 @@ internal class TangemCardTypesResolver(
override fun isBadWallet(): Boolean = card.batchId == BAD_WALLET_BATCH_ID
override fun isJrWallet(): Boolean = card.batchId == JR_WALLET_BATCH_ID
override fun isGrimWallet(): Boolean = card.batchId == GRIM_WALLET_BATCH_ID
override fun isSatoshiFriendsWallet(): Boolean = card.batchId == SATOSHI_WALLET_BATCH_ID
override fun isWhiteWallet(): Boolean {
return walletData == null && card.firmwareVersion <= FirmwareVersion.HDWalletAvailable
}
@ -138,6 +144,9 @@ internal class TangemCardTypesResolver(
const val TRON_WALLET_BATCH_ID = "AF07"
const val KASPA_WALLET_BATCH_ID = "AF08"
const val BAD_WALLET_BATCH_ID = "AF09"
const val JR_WALLET_BATCH_ID = "AF14"
const val GRIM_WALLET_BATCH_ID = "AF13"
const val SATOSHI_WALLET_BATCH_ID = "AF19"
const val WHITE_WALLET2_BATCH_ID = "AF15"
const val TRILLIANT_WALLET_BATCH_ID = "AF16"
const val AVRORA_WALLET_BATCH_ID = "AF18"

View file

@ -225,6 +225,7 @@ fun Blockchain.amountToCreateAccount(token: Token? = null): BigDecimal? {
Blockchain.Stellar -> if (token?.symbol == NODL) BigDecimal(NODL_AMOUNT_TO_CREATE_ACCOUNT) else BigDecimal.ONE
Blockchain.XRP -> BigDecimal.TEN
Blockchain.Near, Blockchain.NearTestnet -> 0.00182.toBigDecimal()
Blockchain.Aptos, Blockchain.AptosTestnet -> BigDecimal.ZERO
else -> null
}
}

View file

@ -13,4 +13,9 @@ sealed interface LegacyAction : Action {
* BackupAction.CheckForUnfinishedBackup, GlobalAction.Onboarding.StartForUnfinishedBackup
*/
data class StartOnboardingProcess(val scanResponse: ScanResponse, val canSkipBackup: Boolean = true) : LegacyAction
/**
* Sending an email to support when sending transaction failed
*/
data class SendEmailTransactionFailed(val errorMessage: String) : LegacyAction
}

View file

@ -0,0 +1,30 @@
package com.tangem.domain.tokens.model
import java.math.BigDecimal
data class Amount(
val currencySymbol: String,
val value: BigDecimal? = null,
val decimals: Int,
val type: AmountType = AmountType.CoinType,
)
sealed class AmountType {
object CoinType : AmountType()
object ReserveType : AmountType()
data class TokenType(val token: CryptoCurrency.Token) : AmountType()
data class FiatType(val code: String) : AmountType()
}
/** Converts `BigDecimal` [cryptoCurrency] to [Amount] */
fun BigDecimal.convertToAmount(cryptoCurrency: CryptoCurrency) = Amount(
currencySymbol = cryptoCurrency.symbol,
value = this,
decimals = cryptoCurrency.decimals,
type = when (cryptoCurrency) {
is CryptoCurrency.Coin -> AmountType.CoinType
is CryptoCurrency.Token -> AmountType.TokenType(
token = cryptoCurrency,
)
},
)

View file

@ -30,6 +30,8 @@ sealed class CryptoCurrencyWarning {
val amountCurrency: CryptoCurrency,
) : CryptoCurrencyWarning()
object TopUpWithoutReserve : CryptoCurrencyWarning()
/**
* Represents wallet blockchain rent
* @param rent Amount that will be charged in overtime if the blockchain does not have an amount greater than

View file

@ -0,0 +1,97 @@
package com.tangem.domain.tokens
import arrow.core.Either
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.math.BigDecimal
/**
* Use case for getting balance not enough warning to cover fee.
*
* This warning is shown when current currency is not paying fee and paying fee currency balance is not enough
*
* Current | Paying fee | Warning
* Coin | Coin | -
* Token | Coin | +
* Coin | PToken | + (VTO - VTHO)
* Token | PToken | + (Other VeChainToken - VTHO)
* PToken | PToken | - (VTHO - VTHO or TerraToken - TerraToken)
*/
class GetBalanceNotEnoughForFeeWarningUseCase(
private val currenciesRepository: CurrenciesRepository,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend operator fun invoke(
fee: BigDecimal,
userWalletId: UserWalletId,
tokenStatus: CryptoCurrencyStatus,
coinStatus: CryptoCurrencyStatus,
): Either<Throwable, CryptoCurrencyWarning?> = Either.catch {
withContext(dispatchers.io) {
val feePaidCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, tokenStatus.currency)
val coinBalance = coinStatus.value.amount ?: BigDecimal.ZERO
val isFeePaidByCoin = tokenStatus.currency is CryptoCurrency.Token
val isFeePaidByToken =
feePaidCurrency is FeePaidCurrency.Token && tokenStatus.currency.id != feePaidCurrency.tokenId
val warning = when {
feePaidCurrency is FeePaidCurrency.Coin && isFeePaidByCoin && fee > coinBalance -> {
CryptoCurrencyWarning.BalanceNotEnoughForFee(
tokenCurrency = tokenStatus.currency,
coinCurrency = coinStatus.currency,
)
}
feePaidCurrency is FeePaidCurrency.Token && isFeePaidByToken && fee > feePaidCurrency.balance -> {
constructTokenBalanceNotEnoughWarning(
userWalletId = userWalletId,
tokenStatus = tokenStatus,
feePaidToken = feePaidCurrency,
)
}
else -> null
}
warning
}
}
/**
* Check if fee paying token [feePaidToken] is added to wallet [userWalletId]
*/
private suspend fun constructTokenBalanceNotEnoughWarning(
userWalletId: UserWalletId,
tokenStatus: CryptoCurrencyStatus,
feePaidToken: FeePaidCurrency.Token,
): CryptoCurrencyWarning {
val token = currenciesRepository
.getMultiCurrencyWalletCurrenciesSync(userWalletId)
.find {
it is CryptoCurrency.Token &&
it.contractAddress.equals(feePaidToken.contractAddress, ignoreCase = true) &&
it.network.derivationPath == tokenStatus.currency.network.derivationPath
}
return if (token != null) {
CryptoCurrencyWarning.CustomTokenNotEnoughForFee(
currency = tokenStatus.currency,
feeCurrency = token,
networkName = token.network.name,
feeCurrencyName = feePaidToken.name,
feeCurrencySymbol = feePaidToken.symbol,
)
} else {
CryptoCurrencyWarning.CustomTokenNotEnoughForFee(
currency = tokenStatus.currency,
feeCurrency = null,
networkName = tokenStatus.currency.network.name,
feeCurrencyName = feePaidToken.name,
feeCurrencySymbol = feePaidToken.symbol,
)
}
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.domain.tokens
import arrow.core.Either
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.error.mapper.mapToTokenListError
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.error.mapper.mapToCurrencyError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations
@ -22,7 +22,7 @@ class GetCryptoCurrencyStatusSyncUseCase(
suspend operator fun invoke(
userWalletId: UserWalletId,
cryptoCurrencyId: CryptoCurrency.ID,
): Either<TokenListError, CryptoCurrencyStatus> {
): Either<CurrencyStatusError, CryptoCurrencyStatus> {
val operations = CurrenciesStatusesOperations(
userWalletId = userWalletId,
currenciesRepository = currenciesRepository,
@ -31,6 +31,18 @@ class GetCryptoCurrencyStatusSyncUseCase(
)
return operations.getCurrencyStatusSync(cryptoCurrencyId)
.mapLeft { error -> error.mapToTokenListError() }
.mapLeft { error -> error.mapToCurrencyError() }
}
suspend operator fun invoke(userWalletId: UserWalletId): Either<CurrencyStatusError, CryptoCurrencyStatus> {
val operations = CurrenciesStatusesOperations(
userWalletId = userWalletId,
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
)
return operations.getPrimaryCurrencyStatusSync()
.mapLeft { error -> error.mapToCurrencyError() }
}
}

View file

@ -254,10 +254,14 @@ class GetCurrencyWarningsUseCase(
private fun getNetworkNoAccountWarning(currencyStatus: CryptoCurrencyStatus): CryptoCurrencyWarning? {
return (currencyStatus.value as? CryptoCurrencyStatus.NoAccount)?.let {
CryptoCurrencyWarning.SomeNetworksNoAccount(
amountToCreateAccount = it.amountToCreateAccount,
amountCurrency = currencyStatus.currency,
)
if (networksRepository.isNeedToCreateAccountWithoutReserve(network = currencyStatus.currency.network)) {
CryptoCurrencyWarning.TopUpWithoutReserve
} else {
CryptoCurrencyWarning.SomeNetworksNoAccount(
amountToCreateAccount = it.amountToCreateAccount,
amountCurrency = currencyStatus.currency,
)
}
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.domain.tokens
import arrow.core.Either
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
/**
* Use case for checking if currency amount can be subtracted.
* Amount can be subtracted if only it is paying fee
*/
class IsAmountSubtractAvailableUseCase(
private val currenciesRepository: CurrenciesRepository,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Either<Throwable, Boolean> =
Either.catch {
withContext(dispatchers.io) {
when (val feeCurrency = currenciesRepository.getFeePaidCurrency(userWalletId, currency)) {
is FeePaidCurrency.Coin -> currency is CryptoCurrency.Coin
is FeePaidCurrency.SameCurrency -> true
is FeePaidCurrency.Token -> currency.id == feeCurrency.tokenId
}
}
}
}

View file

@ -8,43 +8,41 @@ import java.math.BigDecimal
sealed class TradeCryptoAction : Action {
data class SendCrypto(
val currencyId: String,
data class FinishSelling(val transactionId: String) : TradeCryptoAction()
data class Buy(
val userWallet: UserWallet,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrencyCode: String,
val checkUserLocation: Boolean = true,
) : TradeCryptoAction()
data class Sell(
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrencyCode: String,
) : TradeCryptoAction()
data class SendToken(
val userWallet: UserWallet,
val tokenCurrency: CryptoCurrency.Token,
val tokenFiatRate: BigDecimal?,
val coinFiatRate: BigDecimal?,
val feeCurrencyStatus: CryptoCurrencyStatus?,
val transactionInfo: TransactionInfo? = null,
) : TradeCryptoAction()
data class SendCoin(
val userWallet: UserWallet,
val coinStatus: CryptoCurrencyStatus,
val feeCurrencyStatus: CryptoCurrencyStatus?,
val transactionInfo: TransactionInfo? = null,
) : TradeCryptoAction()
data class Swap(val cryptoCurrency: CryptoCurrency) : TradeCryptoAction()
data class TransactionInfo(
val amount: String,
val destinationAddress: String,
val transactionId: String,
) : TradeCryptoAction()
data class FinishSelling(val transactionId: String) : TradeCryptoAction()
sealed class New : TradeCryptoAction() {
data class Buy(
val userWallet: UserWallet,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrencyCode: String,
val checkUserLocation: Boolean = true,
) : New()
data class Sell(
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrencyCode: String,
) : New()
data class SendToken(
val userWallet: UserWallet,
val tokenCurrency: CryptoCurrency.Token,
val tokenFiatRate: BigDecimal?,
val coinFiatRate: BigDecimal?,
val feeCurrencyStatus: CryptoCurrencyStatus?,
) : New()
data class SendCoin(
val userWallet: UserWallet,
val coinStatus: CryptoCurrencyStatus,
val feeCurrencyStatus: CryptoCurrencyStatus?,
) : New()
data class Swap(val cryptoCurrency: CryptoCurrency) : New()
}
)
}

View file

@ -11,6 +11,8 @@ import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
// FIXME: Refactor - [REDACTED_JIRA]
@Suppress("LargeClass")
internal class CurrenciesStatusesOperations(
private val currenciesRepository: CurrenciesRepository,
private val quotesRepository: QuotesRepository,
@ -107,6 +109,27 @@ internal class CurrenciesStatusesOperations(
}
}
suspend fun getPrimaryCurrencyStatusSync(): Either<Error, CryptoCurrencyStatus> = either {
val currency = catch(
block = { currenciesRepository.getSingleCurrencyWalletPrimaryCurrency(userWalletId) },
catch = { raise(Error.DataError(it)) },
)
val quotes = catch(
block = { quotesRepository.getQuoteSync(currency.id).right() },
catch = { Error.DataError(it).left() },
)
val networkStatus = catch(
block = {
networksRepository.getNetworkStatusesSync(userWalletId, setOf(currency.network))
.firstOrNull { it.network == currency.network }
.right()
},
catch = { Error.DataError(it).left() },
)
return createCurrencyStatus(currency, quotes, networkStatus)
}
fun getCardCurrenciesStatusesFlow(): Flow<Either<Error, List<CryptoCurrencyStatus>>> {
return flow {
val nonEmptyCurrencies = recover(

View file

@ -41,6 +41,8 @@ interface NetworksRepository {
suspend fun getNetworkStatusesSync(
userWalletId: UserWalletId,
networks: Set<Network>,
refresh: Boolean,
refresh: Boolean = false,
): Set<NetworkStatus>
fun isNeedToCreateAccountWithoutReserve(network: Network): Boolean
}

View file

@ -32,4 +32,6 @@ internal class MockNetworksRepository(
): Set<NetworkStatus> {
return getNetworkStatusesUpdates(userWalletId, networks).first()
}
override fun isNeedToCreateAccountWithoutReserve(network: Network) = false
}

View file

@ -13,9 +13,13 @@ dependencies {
implementation(deps.arrow.core)
implementation(projects.core.utils)
implementation(projects.core.ui)
/** Tangem SDKs */
implementation(deps.tangem.card.core)
implementation(deps.tangem.card.android) {
exclude(module = "joda-time")
}
implementation(deps.tangem.blockchain)
implementation(projects.domain.models)

View file

@ -1,8 +1,10 @@
package com.tangem.domain.transaction
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.CommonSigner
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
@ -17,4 +19,11 @@ interface TransactionRepository {
userWalletId: UserWalletId,
network: Network,
): TransactionData?
suspend fun sendTransaction(
txData: TransactionData,
signer: CommonSigner,
userWalletId: UserWalletId,
network: Network,
): SimpleResult
}

View file

@ -1,5 +1,7 @@
package com.tangem.domain.transaction.error
import com.tangem.core.ui.extensions.TextReference
sealed class SendTransactionError {
object DemoCardError : SendTransactionError()
@ -8,9 +10,12 @@ sealed class SendTransactionError {
data class NetworkError(val message: String?) : SendTransactionError()
data class BlockchainSdkError(val code: Int, val cause: Throwable?) : SendTransactionError()
data class BlockchainSdkError(val code: Int, val message: String?) : SendTransactionError()
object UserCancelledError : SendTransactionError()
data class TangemSdkError(val code: Int, val cause: Throwable?) : SendTransactionError()
data class TangemSdkError(val code: Int, val messageReference: TextReference) : SendTransactionError()
data class UnknownError(val ex: Exception? = null) : SendTransactionError()
companion object {

View file

@ -1,21 +1,15 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.math.BigDecimal
/**
@ -23,16 +17,15 @@ import java.math.BigDecimal
*/
class GetFeeUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val dispatcher: CoroutineDispatcherProvider,
) {
suspend operator fun invoke(
amount: BigDecimal,
destination: String,
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Flow<Either<GetFeeError, TransactionFee>> {
return flow {
try {
) = either {
catch(
block = {
val result = requireNotNull(
walletManagersFacade.getFee(
amount = convertCryptoCurrencyToAmount(cryptoCurrency, amount),
@ -43,14 +36,15 @@ class GetFeeUseCase(
) { "Fee is null" }
val maybeFee = when (result) {
is Result.Success -> result.data.right()
is Result.Failure -> GetFeeError.DataError(result.error).left()
is Result.Success -> result.data
is Result.Failure -> raise(GetFeeError.DataError(result.error))
}
emit(maybeFee)
} catch (e: Exception) {
emit(GetFeeError.DataError(e.cause).left())
}
}.flowOn(dispatcher.io)
maybeFee
},
catch = {
raise(GetFeeError.DataError(it))
},
)
}
private fun convertCryptoCurrencyToAmount(cryptoCurrency: CryptoCurrency, amount: BigDecimal) = Amount(

View file

@ -8,19 +8,23 @@ import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.blockchain.network.ResultChecker
import com.tangem.common.core.TangemSdkError
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.transaction.R
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.error.SendTransactionError.Companion.USER_CANCELLED_ERROR_CODE
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.sdk.extensions.localizedDescriptionRes
class SendTransactionUseCase(
private val isDemoCardUseCase: IsDemoCardUseCase,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val walletManagersFacade: WalletManagersFacade,
private val transactionRepository: TransactionRepository,
) {
suspend operator fun invoke(
txData: TransactionData,
@ -37,7 +41,7 @@ class SendTransactionUseCase(
if (isDemoCardUseCase(cardId = userWallet.cardId)) {
SendTransactionError.DemoCardError.left()
} else {
walletManagersFacade.sendTransaction(
transactionRepository.sendTransaction(
txData = txData,
signer = signer,
userWalletId = userWallet.walletId,
@ -64,29 +68,28 @@ class SendTransactionUseCase(
private fun handleError(result: SimpleResult.Failure): SendTransactionError {
if (ResultChecker.isNetworkError(result)) return SendTransactionError.NetworkError(result.error.message)
val error = result.error as? BlockchainSdkError ?: return SendTransactionError.UnknownError()
when (error) {
return when (error) {
is BlockchainSdkError.WrappedTangemError -> {
val errorByCode = mapErrorByCode(error)
if (errorByCode != null) {
return errorByCode
if (error.code == USER_CANCELLED_ERROR_CODE) {
SendTransactionError.UserCancelledError
} else {
val tangemError = error.tangemError
if (tangemError is TangemSdkError) {
val resource = tangemError.localizedDescriptionRes()
val resId = resource.resId ?: R.string.common_unknown_error
val resArgs = resource.args.map { it.value }
val textReference = resourceReference(resId, wrappedList(resArgs))
SendTransactionError.TangemSdkError(tangemError.code, textReference)
} else {
SendTransactionError.BlockchainSdkError(error.code, tangemError.customMessage)
}
}
val tangemSdkError = error.tangemError as? TangemSdkError ?: return SendTransactionError.UnknownError()
if (tangemSdkError is TangemSdkError.UserCancelled) return SendTransactionError.UserCancelledError
return SendTransactionError.TangemSdkError(tangemSdkError.code, tangemSdkError.cause)
}
else -> {
return SendTransactionError.TangemSdkError(error.code, error.cause)
}
}
}
private fun mapErrorByCode(error: BlockchainSdkError.WrappedTangemError): SendTransactionError? {
return when (error.code) {
USER_CANCELLED_ERROR_CODE -> {
return SendTransactionError.UserCancelledError
}
else -> {
null
SendTransactionError.BlockchainSdkError(
code = error.code,
message = error.customMessage,
)
}
}
}

View file

@ -10,7 +10,6 @@ android {
}
dependencies {
implementation(projects.domain.tokens.models)
/** AndroidX */
implementation(deps.androidx.fragment.ktx)

View file

@ -23,6 +23,7 @@ dependencies {
implementation(deps.lifecycle.compose)
implementation(deps.jodatime)
implementation(deps.timber)
implementation(deps.reKotlin)
/** Compose */
implementation(deps.compose.accompanist.systemUiController)

View file

@ -23,6 +23,7 @@ internal class DefaultSendRouter(
}
override fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) {
reduxNavController.popBackStack()
reduxNavController.navigate(
action = NavigationAction.NavigateTo(
screen = AppScreen.WalletDetails,

View file

@ -0,0 +1,52 @@
package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.send.impl.R
@Immutable
internal sealed class SendAlertState {
abstract val title: TextReference?
abstract val message: TextReference
open val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
open val onConfirmClick: (() -> Unit)? = null
data class GenericError(
override val title: TextReference? = resourceReference(id = R.string.send_alert_transaction_failed_title),
override val onConfirmClick: (() -> Unit),
) : SendAlertState() {
override val message: TextReference = resourceReference(R.string.common_unknown_error)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.send_alert_button_request_support)
}
data class TransactionError(
val code: String,
val cause: String?,
val causeTextReference: TextReference? = null,
override val onConfirmClick: (() -> Unit),
) : SendAlertState() {
override val title: TextReference = resourceReference(id = R.string.send_alert_transaction_failed_title)
override val message: TextReference = resourceReference(
id = R.string.send_alert_transaction_failed_text,
formatArgs = wrappedList(causeTextReference ?: cause.orEmpty(), code),
)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.send_alert_button_request_support)
}
data class DemoMode(
override val onConfirmClick: () -> Unit,
) : SendAlertState() {
override val title: TextReference = resourceReference(id = R.string.warning_demo_mode_title)
override val message: TextReference = resourceReference(id = R.string.warning_demo_mode_message)
}
object FeeIncreased : SendAlertState() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_notification_high_fee_title)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class SendEvent {
data class ShowSnackBar(val text: TextReference) : SendEvent()
data class ShowAlert(val alert: SendAlertState) : SendEvent()
}

View file

@ -0,0 +1,89 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.getFee
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import java.math.BigDecimal
/**
* Factory to produce event state for [SendUiState]
*
* @param currentStateProvider [Provider] of [SendUiState]
* @param clickIntents [SendClickIntents]
* @param feeStateFactory [FeeStateFactory]
*/
internal class SendEventStateFactory(
private val currentStateProvider: Provider<SendUiState>,
private val clickIntents: SendClickIntents,
private val feeStateFactory: FeeStateFactory,
) {
private val sendTransactionErrorConverter by lazy { SendTransactionAlertConverter(clickIntents) }
fun onConsumeEventState(): SendUiState {
return currentStateProvider().copy(event = consumedEvent())
}
fun getSendTransactionErrorState(error: SendTransactionError?, onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
val event = error?.let {
sendTransactionErrorConverter.convert(error)?.let {
triggeredEvent<SendEvent>(SendEvent.ShowAlert(it), onConsume)
}
}
return state.copy(
event = event ?: consumedEvent(),
)
}
fun getFeeUpdatedAlert(fee: TransactionFee, onConsume: () -> Unit, onFeeNotIncreased: () -> Unit): SendUiState {
val state = currentStateProvider()
val feeSelector = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return state
val newFee = when (fee) {
is TransactionFee.Single -> fee.normal
is TransactionFee.Choosable -> {
when (feeSelector.selectedFee) {
FeeType.SLOW -> fee.minimum
FeeType.MARKET -> fee.normal
FeeType.FAST -> fee.priority
FeeType.CUSTOM -> return state
}
}
}
val newFeeValue = newFee.amount.value ?: BigDecimal.ZERO
val oldFeeValue = feeSelector.getFee().amount.value ?: BigDecimal.ZERO
val updateFeeState = feeStateFactory.onFeeOnLoadedState(fee)
return if (newFeeValue > oldFeeValue) {
updateFeeState.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(SendAlertState.FeeIncreased),
onConsume = onConsume,
),
)
} else {
onFeeNotIncreased()
updateFeeState
}
}
fun getGenericErrorState(error: Throwable? = null, onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.GenericError(
onConfirmClick = { clickIntents.onFailedTxEmailClick(error?.localizedMessage.orEmpty()) },
),
),
onConsume = onConsume,
),
)
}
}

View file

@ -57,16 +57,28 @@ internal sealed class SendNotification(val config: NotificationConfig) {
sealed class Warning(
title: TextReference,
subtitle: TextReference,
buttonsState: NotificationConfig.ButtonsState? = null,
) : SendNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.img_attention_20,
buttonsState = buttonsState,
),
) {
data class HighFeeError(val amount: String) : Warning(
data class HighFeeError(
val amount: String,
val onConfirmClick: () -> Unit,
val onDismissClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
buttonsState = NotificationConfig.ButtonsState.PairButtonsConfig(
primaryText = resourceReference(R.string.send_notification_fee_too_high_accept, wrappedList(amount)),
onPrimaryClick = onConfirmClick,
secondaryText = resourceReference(R.string.send_notification_fee_too_high_ignore),
onSecondaryClick = onDismissClick,
),
)
data class ExistentialDeposit(val deposit: String) : Warning(

View file

@ -6,6 +6,7 @@ import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -21,6 +22,7 @@ internal class SendNotificationFactory(
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val walletManagersFacade: WalletManagersFacade,
private val clickIntents: SendClickIntents,
) {
fun create(): Flow<ImmutableList<SendNotification>> = currentStateProvider().currentState
@ -30,19 +32,33 @@ internal class SendNotificationFactory(
val feeState = state.feeState ?: return@map persistentListOf()
val recipientState = state.recipientState ?: return@map persistentListOf()
val feeAmount = feeState.fee?.amount?.value ?: BigDecimal.ZERO
val amountValue = state.amountState?.amountTextField?.value?.toBigDecimalOrNull() ?: BigDecimal.ZERO
val sendAmount = if (feeState.isSubtract) feeState.receivedAmountValue else amountValue
buildList {
// errors
addExceedBalanceNotification(feeAmount, feeState.receivedAmountValue)
addInvalidAmountNotification(feeState.isSubtract, feeState.receivedAmountValue)
addMinimumAmountErrorNotification(feeAmount, feeState.receivedAmountValue)
addExceedBalanceNotification(feeAmount, sendAmount)
addInvalidAmountNotification(feeState.isSubtract, sendAmount)
addMinimumAmountErrorNotification(feeAmount, sendAmount)
addReserveAmountErrorNotification(recipientState.addressTextField.value)
addTransactionLimitErrorNotification(feeAmount, feeState.receivedAmountValue)
addTransactionLimitErrorNotification(feeAmount, sendAmount)
// warnings
addExistentialWarningNotification(feeAmount, feeState.receivedAmountValue)
addHighFeeWarningNotification()
addExistentialWarningNotification(feeAmount, sendAmount)
addHighFeeWarningNotification(amountValue, state.sendState.ignoreAmountReduce)
}.toImmutableList()
}
fun dismissHighFeeWarningState(): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState
val updatedNotifications = sendState.notifications.filterNot { it is SendNotification.Warning.HighFeeError }
return state.copy(
sendState = sendState.copy(
ignoreAmountReduce = true,
notifications = updatedNotifications.toImmutableList(),
),
)
}
private fun MutableList<SendNotification>.addExceedBalanceNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
@ -173,16 +189,30 @@ internal class SendNotificationFactory(
}
}
private fun MutableList<SendNotification>.addHighFeeWarningNotification() {
// TODO Move Blockchain check elsewhere
if (cryptoCurrencyStatusProvider().currency.network.id.value == Blockchain.Tezos.id) {
add(SendNotification.Warning.HighFeeError(TEZOS_FEE_THRESHOLD))
private fun MutableList<SendNotification>.addHighFeeWarningNotification(
sendAmount: BigDecimal,
ignoreAmountReduce: Boolean,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val isTezos = cryptoCurrencyStatus.currency.network.id.value == Blockchain.Tezos.id
if (!ignoreAmountReduce && sendAmount == balance && isTezos) {
add(
SendNotification.Warning.HighFeeError(
amount = TEZOS_FEE_THRESHOLD.toPlainString(),
onConfirmClick = {
val reduceTo = sendAmount.minus(TEZOS_FEE_THRESHOLD).toPlainString()
clickIntents.onAmountReduceClick(reduceTo)
},
onDismissClick = clickIntents::onAmountReduceIgnoreClick,
),
)
}
}
companion object {
private const val CARDANO_MINIMUM = "1"
private const val DOGECOIN_MINIMUM = "0.01"
private const val TEZOS_FEE_THRESHOLD = "0.01"
private val TEZOS_FEE_THRESHOLD = BigDecimal("0.01")
}
}

View file

@ -3,10 +3,9 @@ package com.tangem.features.send.impl.presentation.state
import androidx.paging.PagingData
import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
@ -15,23 +14,21 @@ import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.ValidateWalletMemoUseCase
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.amount.SendAmountCurrencyConverter
import com.tangem.features.send.impl.presentation.state.amount.SendAmountStateConverter
import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.features.send.impl.presentation.state.fee.SendFeeStateConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldChangeConverter
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientListConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientStateConverter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import timber.log.Timber
import java.math.BigDecimal
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList")
internal class SendStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
@ -40,25 +37,28 @@ internal class SendStateFactory(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val amountFieldConverter by lazy { SendAmountFieldConverter(clickIntents) }
private val amountFieldChangeConverter by lazy { SendAmountFieldChangeConverter(currentStateProvider) }
private val customFeeFieldConverter by lazy {
SendFeeCustomFieldConverter(
private val amountFieldConverter by lazy {
SendAmountFieldConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
appCurrencyProvider = appCurrencyProvider,
)
}
private val feeNotificationFactory = FeeNotificationFactory(
coinCryptoCurrencyStatusProvider = coinCryptoCurrencyStatusProvider,
userWalletProvider = userWalletProvider,
clickIntents = clickIntents,
)
private val amountFieldChangeConverter by lazy {
SendAmountFieldChangeConverter(
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val amountCurrencyConverter by lazy {
SendAmountCurrencyConverter(
currentStateProvider = currentStateProvider,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val amountStateConverter by lazy {
SendAmountStateConverter(
@ -75,11 +75,7 @@ internal class SendStateFactory(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val feeStateConverter by lazy {
SendFeeStateConverter(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
private val feeStateConverter by lazy { SendFeeStateConverter() }
private val recipientListStateConverter by lazy {
SendRecipientListConverter(
@ -92,6 +88,7 @@ internal class SendStateFactory(
fun getInitialState(): SendUiState = SendUiState(
clickIntents = clickIntents,
currentState = MutableStateFlow(SendUiStateType.Amount),
event = consumedEvent(),
)
fun getReadyState(): SendUiState {
@ -107,16 +104,7 @@ internal class SendStateFactory(
//region amount state clicks
fun getOnAmountValueChange(value: String) = amountFieldChangeConverter.convert(value)
fun getOnCurrencyChangedState(isFiat: Boolean): SendUiState {
val state = currentStateProvider()
val amountState = state.amountState ?: return state
return if (amountState.isFiatValue == isFiat) {
state
} else {
return state.copy(amountState = amountState.copy(isFiatValue = isFiat))
}
}
fun getOnCurrencyChangedState(isFiat: Boolean) = amountCurrencyConverter.convert(isFiat)
//endregion
//region recipient
@ -218,144 +206,6 @@ internal class SendStateFactory(
}
//endregion
//region fee
fun onFeeOnLoadingState(): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = FeeSelectorState.Loading
return state.copy(
feeState = feeState.copy(
feeSelectorState = feeSelectorState,
notifications = persistentListOf(),
isPrimaryButtonEnabled = feeSelectorState.isPrimaryButtonEnabled(),
),
)
}
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = FeeSelectorState.Content(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee, feeState.isSubtract)
val updatedState = feeState.copy(
feeSelectorState = feeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(feeState = updatedState),
isPrimaryButtonEnabled = feeSelectorState.isPrimaryButtonEnabled(),
),
)
}
fun onFeeSelectedState(feeType: FeeType): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType)
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee, feeState.isSubtract)
val updatedState = feeState.copy(
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
feeSelectorState = updatedFeeSelectorState,
isPrimaryButtonEnabled = updatedFeeSelectorState.isPrimaryButtonEnabled(),
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(feeState = updatedState),
),
)
}
fun onCustomFeeValueChange(index: Int, value: String): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = feeSelectorState.copy(
customValues = feeSelectorState.customValues.toMutableList().apply {
set(index, feeSelectorState.customValues[index].copy(value = value))
}.toImmutableList(),
)
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee, feeState.isSubtract)
val updatedState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isPrimaryButtonEnabled = updatedFeeSelectorState.isPrimaryButtonEnabled(),
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(feeState = updatedState),
),
)
}
fun onSubtractSelect(value: Boolean): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee, value)
val updatedState = feeState.copy(
isSubtract = value,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = if (value) {
getFormattedValue(receivedAmount)
} else {
feeState.receivedAmount
},
)
return state.copy(
feeState = updatedState.copy(
notifications = feeNotificationFactory(feeState = updatedState),
),
)
}
private fun FeeSelectorState.isPrimaryButtonEnabled(): Boolean {
return when (this) {
is FeeSelectorState.Loading -> false
is FeeSelectorState.Content -> {
val customValue = customValues.firstOrNull()?.value?.toBigDecimalOrNull()
val balance = cryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = getFee().amount.value ?: BigDecimal.ZERO
val isNotEmptyCustom = !customValue.isNullOrZero() && selectedFee == FeeType.CUSTOM
val isNotCustom = selectedFee != FeeType.CUSTOM
fee < balance && (isNotEmptyCustom || isNotCustom)
}
}
}
private fun getFormattedValue(value: BigDecimal): String {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
return BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = value,
cryptoCurrency = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
//endregion
//region send
fun getSendingStateUpdate(isSending: Boolean): SendUiState {
val state = currentStateProvider()
@ -375,6 +225,7 @@ internal class SendStateFactory(
transactionDate = txData.date?.timeInMillis ?: System.currentTimeMillis(),
isSuccess = true,
txUrl = txUrl,
notifications = persistentListOf(),
),
)
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.converter.Converter
internal class SendTransactionAlertConverter(
private val clickIntents: SendClickIntents,
) : Converter<SendTransactionError, SendAlertState?> {
override fun convert(value: SendTransactionError): SendAlertState? {
return when (value) {
SendTransactionError.DemoCardError -> SendAlertState.DemoMode(
onConfirmClick = { clickIntents.popBackStack() },
)
is SendTransactionError.TangemSdkError -> SendAlertState.TransactionError(
code = value.code.toString(),
cause = null,
causeTextReference = value.messageReference,
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.code.toString()) },
)
is SendTransactionError.BlockchainSdkError -> SendAlertState.TransactionError(
code = value.code.toString(),
cause = value.message,
onConfirmClick = { clickIntents.onFailedTxEmailClick("${value.code}: ${value.message.orEmpty()}") },
)
is SendTransactionError.DataError -> SendAlertState.TransactionError(
code = "",
cause = value.message,
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.NetworkError -> SendAlertState.TransactionError(
code = "",
cause = value.message,
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.UnknownError -> SendAlertState.TransactionError(
code = "",
cause = value.ex?.localizedMessage,
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.ex?.localizedMessage.orEmpty()) },
)
else -> null
}
}
}

View file

@ -5,8 +5,8 @@ import androidx.compose.runtime.Stable
import androidx.paging.PagingData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.amount.SendAmountSegmentedButtonsConfig
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
@ -31,6 +31,7 @@ internal data class SendUiState(
val sendState: SendStates.SendState = SendStates.SendState(),
val recipientList: MutableStateFlow<PagingData<SendRecipientListContent>> = MutableStateFlow(PagingData.empty()),
val currentState: MutableStateFlow<SendUiStateType>,
val event: StateEvent<SendEvent>,
)
@Stable
@ -44,14 +45,11 @@ internal sealed class SendStates {
data class AmountState(
override val type: SendUiStateType = SendUiStateType.Amount,
override val isPrimaryButtonEnabled: Boolean,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrency: AppCurrency,
val walletName: String,
val walletBalance: String,
val walletBalance: TextReference,
val tokenIconState: TokenIconState,
val isFiatValue: Boolean,
val segmentedButtonConfig: PersistentList<SendAmountSegmentedButtonsConfig>,
val amountTextField: SendTextField.Amount,
val amountTextField: SendTextField.AmountField,
) : SendStates()
/** Recipient state */
@ -69,13 +67,14 @@ internal sealed class SendStates {
data class FeeState(
override val type: SendUiStateType = SendUiStateType.Fee,
override val isPrimaryButtonEnabled: Boolean = false,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val feeSelectorState: FeeSelectorState = FeeSelectorState.Loading,
val isSubtract: Boolean = false,
val fee: Fee? = null,
val receivedAmountValue: BigDecimal = BigDecimal.ZERO,
val receivedAmount: String = "",
val notifications: ImmutableList<SendFeeNotification> = persistentListOf(),
val feeSelectorState: FeeSelectorState,
val isSubtractAvailable: Boolean,
val isSubtract: Boolean,
val isUserSubtracted: Boolean,
val fee: Fee?,
val receivedAmountValue: BigDecimal,
val receivedAmount: String,
val notifications: ImmutableList<SendFeeNotification>,
) : SendStates()
/** Send state */
@ -86,6 +85,7 @@ internal sealed class SendStates {
val isSuccess: Boolean = false,
val transactionDate: Long = 0L,
val txUrl: String = "",
val ignoreAmountReduce: Boolean = false,
val notifications: ImmutableList<SendNotification> = persistentListOf(),
) : SendStates()
}

View file

@ -11,22 +11,16 @@ internal class StateRouter(
var currentState: MutableStateFlow<SendUiStateType> = MutableStateFlow(SendUiStateType.Recipient)
private set
private var isFromSend: Boolean = false
fun popBackStack() {
fragmentManager.get()?.popBackStack()
}
fun onBackClick() {
if (isFromSend) {
showSend()
} else {
when (currentState.value) {
SendUiStateType.Recipient -> popBackStack()
SendUiStateType.Amount -> showRecipient()
SendUiStateType.Fee -> showAmount()
SendUiStateType.Send -> showFee()
}
when (currentState.value) {
SendUiStateType.Recipient -> popBackStack()
SendUiStateType.Amount -> showRecipient()
SendUiStateType.Fee -> showAmount()
SendUiStateType.Send -> showFee()
}
}
@ -48,18 +42,15 @@ internal class StateRouter(
}
}
fun showAmount(isFromSend: Boolean = false) {
this.isFromSend = isFromSend
fun showAmount() {
currentState.update { SendUiStateType.Amount }
}
fun showRecipient(isFromSend: Boolean = false) {
this.isFromSend = isFromSend
fun showRecipient() {
currentState.update { SendUiStateType.Recipient }
}
fun showFee(isFromSend: Boolean = false) {
this.isFromSend = isFromSend
fun showFee() {
currentState.update { SendUiStateType.Fee }
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.send.impl.presentation.state.amount
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
internal class SendAmountCurrencyConverter(
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Boolean, SendUiState> {
override fun convert(value: Boolean): SendUiState {
val state = currentStateProvider()
val amountState = state.amountState ?: return state
val amountTextField = amountState.amountTextField
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val isValidFiatRate = cryptoCurrencyStatus.value.fiatRate.isNullOrZero()
return if (amountTextField.isFiatValue == value && !isValidFiatRate) {
state
} else {
return state.copy(
amountState = amountState.copy(
amountTextField = amountTextField.copy(
isFiatValue = value,
),
),
)
}
}
}

View file

@ -1,12 +1,15 @@
package com.tangem.features.send.impl.presentation.state.amount
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter.formatCryptoAmount
import com.tangem.core.ui.utils.BigDecimalFormatter.formatFiatAmount
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fields.SendAmountFieldConverter
import com.tangem.utils.Provider
@ -29,13 +32,10 @@ internal class SendAmountStateConverter(
val crypto = formatCryptoAmount(status.value.amount, status.currency.symbol, status.currency.decimals)
return SendStates.AmountState(
appCurrency = appCurrency,
cryptoCurrencyStatus = status,
walletName = userWallet.name,
walletBalance = "$crypto ($fiat)",
walletBalance = resourceReference(R.string.send_wallet_balance_format, wrappedList(crypto, fiat)),
tokenIconState = iconStateConverter.convert(status),
amountTextField = sendAmountFieldConverter.convert(Unit),
isFiatValue = false,
isPrimaryButtonEnabled = false,
segmentedButtonConfig = persistentListOf(
SendAmountSegmentedButtonsConfig(

View file

@ -2,20 +2,17 @@ package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.features.send.impl.presentation.state.SendUiState
import java.math.BigDecimal
/**
* Calculate receiving amount when fee is subtracted from sending amount
*/
internal fun calculateReceiveAmount(uiState: SendUiState, feeAmount: Fee, isSubtract: Boolean): BigDecimal {
val amount = uiState.amountState?.amountTextField?.value ?: return BigDecimal.ZERO
internal fun calculateReceiveAmount(state: SendUiState, feeAmount: Fee): BigDecimal {
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
val fee = feeAmount.amount.value ?: return BigDecimal.ZERO
return if (isSubtract) {
amount.toBigDecimal().minus(fee)
} else {
amount.toBigDecimal()
}
return amountValue.minus(fee)
}
/**
@ -29,8 +26,7 @@ internal fun FeeSelectorState.Content.getFee(): Fee {
FeeType.MARKET -> fees.normal
FeeType.FAST -> fees.priority
FeeType.CUSTOM -> {
val feeAmount =
customValues.firstOrNull()?.value?.let { BigDecimal(it.ifEmpty { "0" }) } ?: BigDecimal.ZERO
val feeAmount = customValues.firstOrNull()?.value.toBigDecimalOrDefault()
Fee.Common(
fees.normal.amount.copy(
value = feeAmount,

View file

@ -1,35 +1,60 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.presentation.state.SendStates
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.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
internal class FeeNotificationFactory(
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val clickIntents: SendClickIntents,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
) {
operator fun invoke(feeState: SendStates.FeeState): ImmutableList<SendFeeNotification> {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return persistentListOf()
val customFee = feeSelectorState.customValues
val selectedFee = feeSelectorState.selectedFee
fun create() = currentStateProvider().currentState
.filter { it == SendUiStateType.Fee }
.map {
val state = currentStateProvider()
val feeState = state.feeState ?: return@map persistentListOf()
buildList {
when (val feeSelectorState = feeState.feeSelectorState) {
FeeSelectorState.Loading -> Unit
FeeSelectorState.Error -> {
addFeeUnreachableNotification(feeSelectorState)
}
is FeeSelectorState.Content -> {
val customFee = feeSelectorState.customValues
val selectedFee = feeSelectorState.selectedFee
addTooLowNotification(feeSelectorState.fees, selectedFee, customFee)
addTooHighNotification(feeSelectorState.fees, selectedFee, customFee)
addFeeCoverageNotification(feeState, state.amountState)
addExceedsBalanceNotification(feeState.fee)
}
}
}.toImmutableList()
}
return buildList {
addTooLowNotification(feeSelectorState.fees, selectedFee, customFee)
addTooHighNotification(feeSelectorState.fees, selectedFee, customFee)
addFeeCoverageNotification()
addExceedsBalanceNotification(feeSelectorState)
}.toImmutableList()
private fun MutableList<SendFeeNotification>.addFeeUnreachableNotification(feeSelectorState: FeeSelectorState) {
if (feeSelectorState is FeeSelectorState.Error) {
add(SendFeeNotification.Warning.NetworkFeeUnreachable(clickIntents::feeReload))
}
}
private fun MutableList<SendFeeNotification>.addTooLowNotification(
@ -59,35 +84,62 @@ internal class FeeNotificationFactory(
}
}
private fun MutableList<SendFeeNotification>.addFeeCoverageNotification() {
// TODO add fee coverage condition [REDACTED_JIRA]
add(SendFeeNotification.Warning.NetworkCoverage)
private fun MutableList<SendFeeNotification>.addFeeCoverageNotification(
feeState: SendStates.FeeState,
amountState: SendStates.AmountState?,
) {
if (!feeState.isSubtractAvailable) return
val cryptoAmount = coinCryptoCurrencyStatusProvider().value.amount ?: return
val feeValue = feeState.fee?.amount?.value ?: return
val value = amountState?.amountTextField?.cryptoAmount?.value ?: return
if (cryptoAmount <= value + feeValue && feeState.isSubtract && !feeState.isUserSubtracted) {
add(SendFeeNotification.Warning.NetworkCoverage)
}
}
private fun MutableList<SendFeeNotification>.addExceedsBalanceNotification(
feeSelectorState: FeeSelectorState.Content,
) {
val coinCryptoCurrency = coinCryptoCurrencyStatusProvider()
val cryptoAmount = coinCryptoCurrency.value.amount ?: BigDecimal.ZERO
val choosableFee = feeSelectorState.fees as? TransactionFee.Choosable
val fee = when (feeSelectorState.selectedFee) {
FeeType.SLOW -> choosableFee?.minimum?.amount?.value
FeeType.MARKET -> feeSelectorState.fees.normal.amount.value
FeeType.FAST -> choosableFee?.priority?.amount?.value
FeeType.CUSTOM -> feeSelectorState.customValues.firstOrNull()?.value?.toBigDecimalOrNull()
} ?: return
private suspend fun MutableList<SendFeeNotification>.addExceedsBalanceNotification(fee: Fee?) {
val feeValue = fee?.amount?.value ?: BigDecimal.ZERO
val userWalletId = userWalletProvider().walletId
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
if (fee > cryptoAmount) {
add(
SendFeeNotification.Error.ExceedsBalance(
coinCryptoCurrency.currency.networkIconResId,
) {
clickIntents.onTokenDetailsClick(
userWalletProvider().walletId,
coinCryptoCurrency.currency,
)
},
)
val warning = getBalanceNotEnoughForFeeWarningUseCase(
fee = feeValue,
userWalletId = userWalletId,
tokenStatus = cryptoCurrencyStatus,
coinStatus = coinCryptoCurrencyStatusProvider(),
).fold(
ifLeft = { null },
ifRight = { it },
) ?: return
when (warning) {
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> {
add(
SendFeeNotification.Error.ExceedsBalance(
warning.coinCurrency.networkIconResId,
) {
clickIntents.onTokenDetailsClick(
userWalletProvider().walletId,
warning.coinCurrency,
)
},
)
}
is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> {
val currency = warning.feeCurrency ?: warning.currency
add(
SendFeeNotification.Error.ExceedsBalance(
currency.networkIconResId,
) {
clickIntents.onTokenDetailsClick(
userWalletId,
currency,
)
},
)
}
else -> Unit
}
}

View file

@ -16,6 +16,8 @@ internal sealed class FeeSelectorState {
val selectedFee: FeeType = FeeType.MARKET,
val customValues: ImmutableList<SendTextField.CustomFee> = persistentListOf(),
) : FeeSelectorState()
object Error : FeeSelectorState()
}
enum class FeeType {

View file

@ -0,0 +1,217 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
/**
* Factory to produce fee state for [SendUiState]
*/
internal class FeeStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
) {
private val customFeeFieldConverter by lazy {
SendFeeCustomFieldConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
)
}
fun onFeeOnLoadingState(): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
return state.copy(
feeState = feeState.copy(
feeSelectorState = FeeSelectorState.Loading,
notifications = persistentListOf(),
isPrimaryButtonEnabled = false,
),
)
}
fun onFeeOnLoadedState(fees: TransactionFee, isSubtractAvailable: Boolean): SendUiState {
val state = currentStateProvider()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val feeState = state.feeState ?: return state
val feeSelectorState = (feeState.feeSelectorState as? FeeSelectorState.Content)?.copy(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
) ?: FeeSelectorState.Content(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
isSubtractAvailable = isSubtractAvailable,
feeSelectorState = feeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = isSubtractAvailable && checkAutoSubtract(state, fee, balance),
),
)
}
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelector = feeSelectorState.copy(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = updatedFeeSelector.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
feeSelectorState = updatedFeeSelector,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
),
)
}
fun onFeeOnErrorState(): SendUiState {
val state = currentStateProvider()
return state.copy(
feeState = state.feeState?.copy(
feeSelectorState = FeeSelectorState.Error,
),
)
}
fun onFeeSelectedState(feeType: FeeType): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType)
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
fee = fee,
feeSelectorState = updatedFeeSelectorState,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
),
)
}
fun onCustomFeeValueChange(index: Int, value: String): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = feeSelectorState.copy(
customValues = feeSelectorState.customValues.toMutableList().apply {
set(index, feeSelectorState.customValues[index].copy(value = value))
}.toImmutableList(),
)
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = updatedFeeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
),
)
}
fun onSubtractSelect(value: Boolean): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val fee = feeSelectorState.getFee()
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
isSubtract = value,
isUserSubtracted = true,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
fee = fee,
),
)
}
fun getFeeNotificationState(notifications: ImmutableList<SendFeeNotification>): SendUiState {
val state = currentStateProvider()
return state.copy(
feeState = state.feeState?.copy(
notifications = notifications,
isPrimaryButtonEnabled = isPrimaryButtonEnabled(state.feeState, notifications),
),
)
}
private fun isPrimaryButtonEnabled(
feeState: SendStates.FeeState,
notifications: ImmutableList<SendFeeNotification>,
): Boolean {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return false
val customValue = feeSelectorState.customValues.firstOrNull()?.value?.toBigDecimalOrNull()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = feeSelectorState.getFee()
val feeValue = fee.amount.value ?: BigDecimal.ZERO
val isNotCustom = feeSelectorState.selectedFee != FeeType.CUSTOM
val isNotEmptyCustom = !customValue.isNullOrZero() && !isNotCustom
val noErrors = notifications.none { it is SendFeeNotification.Error }
val isSubtractRequired = when {
!feeState.isSubtractAvailable -> true // current currency is not fee currency
feeValue + feeState.receivedAmountValue >= balance -> feeState.isSubtract
else -> feeValue + feeState.receivedAmountValue <= balance
}
return noErrors && isSubtractRequired && (isNotEmptyCustom || isNotCustom)
}
private fun checkAutoSubtract(state: SendUiState, fee: Fee, balance: BigDecimal): Boolean {
val feeState = state.feeState ?: return false
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
val feeAmount = fee.amount.value ?: BigDecimal.ZERO
return if (feeState.isUserSubtracted) {
feeState.isSubtract
} else {
amountValue + feeAmount >= balance
}
}
private fun getFormattedValue(value: BigDecimal): String {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
return BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = value,
cryptoCurrency = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
}

View file

@ -4,13 +4,16 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.toFormattedString
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -31,16 +34,24 @@ internal class SendFeeCustomFieldConverter(
return persistentListOf(
SendTextField.CustomFee(
value = ethereumFee.amount.value.toString(),
value = ethereumFee.amount.value?.toFormattedString(ethereumFee.amount.decimals).orEmpty(),
decimals = ethereumFee.amount.decimals,
symbol = ethereumFee.amount.currencySymbol,
onValueChange = { clickIntents.onCustomFeeValueChange(0, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number,
),
label = TextReference.Str(maxFeeFiat),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer),
label = stringReference(maxFeeFiat),
),
SendTextField.CustomFee(
value = ethereumFee.gasPrice.toString(),
decimals = 0,
symbol = ETHEREUM_UNIT,
title = resourceReference(R.string.send_gas_price),
footer = resourceReference(R.string.send_gas_price_footer),
onValueChange = { clickIntents.onCustomFeeValueChange(1, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
@ -49,6 +60,10 @@ internal class SendFeeCustomFieldConverter(
),
SendTextField.CustomFee(
value = ethereumFee.gasLimit.toString(),
decimals = 0,
symbol = null,
title = resourceReference(R.string.send_gas_limit),
footer = resourceReference(R.string.send_gas_limit_footer),
onValueChange = { clickIntents.onCustomFeeValueChange(2, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
@ -57,4 +72,8 @@ internal class SendFeeCustomFieldConverter(
),
)
}
companion object {
private const val ETHEREUM_UNIT = "GWEI"
}
}

View file

@ -1,13 +1,11 @@
package com.tangem.features.send.impl.presentation.state.fee
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.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.send.impl.R
@Immutable
sealed class SendFeeNotification(val config: NotificationConfig) {
sealed class Informational(
@ -29,11 +27,13 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
sealed class Warning(
val title: TextReference,
val subtitle: TextReference,
val buttonsState: NotificationConfig.ButtonsState? = null,
) : SendFeeNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.img_attention_20,
buttonsState = buttonsState,
),
) {
data class TooHigh(
@ -47,6 +47,15 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
title = resourceReference(id = R.string.send_network_fee_warning_title),
subtitle = resourceReference(id = R.string.send_network_fee_warning_content),
)
data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = onRefresh,
),
)
}
sealed class Error(

View file

@ -1,17 +1,22 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
internal class SendFeeStateConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Unit, SendStates.FeeState> {
internal class SendFeeStateConverter : Converter<Unit, SendStates.FeeState> {
override fun convert(value: Unit): SendStates.FeeState {
return SendStates.FeeState(
cryptoCurrencyStatus = cryptoCurrencyStatusProvider(),
feeSelectorState = FeeSelectorState.Loading,
isSubtractAvailable = false,
isSubtract = false,
isUserSubtracted = false,
fee = null,
receivedAmountValue = BigDecimal.ZERO,
receivedAmount = "",
notifications = persistentListOf(),
)
}
}

View file

@ -1,55 +1,48 @@
package com.tangem.features.send.impl.presentation.state.fields
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
import java.text.NumberFormat
import java.math.RoundingMode
internal class SendAmountFieldChangeConverter(
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<String, SendUiState> {
override fun convert(value: String): SendUiState {
val state = currentStateProvider()
val amountState = state.amountState ?: return state
val amountTextField = amountState.amountTextField
val feeState = state.feeState ?: return state
if (value.checkDecimalSeparatorDuplicate()) return state
if (value.isEmpty()) return state.emptyState()
val fiatRate = amountState.cryptoCurrencyStatus.value.fiatRate
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
val trimmedValue = value.trim()
val cryptoValue = if (amountState.isFiatValue) {
if (value.isNotBlank()) {
trimmedValue.toBigDecimal().divide(fiatRate)?.stripTrailingZeros()?.toPlainString().orEmpty()
} else {
DEFAULT_VALUE
}
} else {
trimmedValue
}
val cryptoValue = trimmedValue.getCryptoValue(amountTextField.isFiatValue, cryptoDecimals)
val fiatValue = trimmedValue.getFiatValue(amountTextField.isFiatValue, fiatDecimals)
val decimalCryptoValue = cryptoValue.parseToBigDecimal(cryptoDecimals)
val decimalFiatValue = fiatValue.parseToBigDecimal(fiatDecimals)
val fiatValue = if (!amountState.isFiatValue) {
if (value.isNotBlank()) {
trimmedValue.toBigDecimal().multiply(fiatRate)?.stripTrailingZeros()?.toPlainString().orEmpty()
} else {
DEFAULT_VALUE
}
} else {
trimmedValue
}
val isExceedBalance = cryptoValue.checkExceedBalance(amountState)
val isMaxAmount = cryptoValue.checkMaxAmount(amountState)
val checkValue = if (amountTextField.isFiatValue) fiatValue else cryptoValue
val isExceedBalance = checkValue.checkExceedBalance(amountTextField)
val isMaxAmount = checkValue.checkMaxAmount(amountTextField)
return state.copy(
amountState = amountState.copy(
isPrimaryButtonEnabled = !isExceedBalance,
amountTextField = amountState.amountTextField.copy(
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,
isError = isExceedBalance,
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
),
),
feeState = feeState.copy(
@ -58,58 +51,63 @@ internal class SendAmountFieldChangeConverter(
)
}
private fun String.getCryptoValue(isFiatValue: Boolean, decimals: Int): String {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val fiatRate = cryptoCurrencyStatus.value.fiatRate
return if (isFiatValue && fiatRate != null) {
parseToBigDecimal(decimals).divide(fiatRate, decimals, RoundingMode.DOWN)
.parseBigDecimal(decimals)
} else {
this
}
}
private fun String.getFiatValue(isFiatValue: Boolean, decimals: Int): String {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val fiatRate = cryptoCurrencyStatus.value.fiatRate
return if (!isFiatValue && fiatRate != null) {
parseToBigDecimal(decimals).multiply(fiatRate).parseBigDecimal(decimals)
} else {
this
}
}
private fun SendUiState.emptyState(): SendUiState {
return copy(
amountState = amountState?.copy(
isPrimaryButtonEnabled = false,
amountTextField = amountState.amountTextField.copy(
value = if (!amountState.isFiatValue) "" else DEFAULT_VALUE,
fiatValue = if (amountState.isFiatValue) "" else DEFAULT_VALUE,
value = "",
fiatValue = "",
isError = false,
),
),
)
}
private fun String.checkDecimalSeparatorDuplicate(): Boolean {
val regex = TRIM_REGEX.toRegex()
val decimalSeparatorCount = regex.findAll(this).count()
return decimalSeparatorCount > 1
}
private fun String.checkExceedBalance(state: SendStates.AmountState): Boolean {
val currencyCryptoAmount = state.cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val currencyFiatAmount = state.cryptoCurrencyStatus.value.fiatAmount ?: BigDecimal.ZERO
return if (state.isFiatValue) {
toBigDecimal() > currencyFiatAmount
private fun String.checkExceedBalance(amountTextField: SendTextField.AmountField): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val currencyFiatAmount = cryptoCurrencyStatus.value.fiatAmount ?: BigDecimal.ZERO
return if (amountTextField.isFiatValue) {
parseToBigDecimal(amountTextField.fiatAmount.decimals) > currencyFiatAmount
} else {
toBigDecimal() > currencyCryptoAmount
parseToBigDecimal(amountTextField.cryptoAmount.decimals) > currencyCryptoAmount
}
}
private fun String.checkMaxAmount(state: SendStates.AmountState): Boolean {
private fun String.checkMaxAmount(amountTextField: SendTextField.AmountField): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
// If current currency is Token
if (state.cryptoCurrencyStatus.currency is CryptoCurrency.Token) return false
if (cryptoCurrencyStatus.currency is CryptoCurrency.Token) return false
val currencyCryptoAmount = state.cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val currencyFiatAmount = state.cryptoCurrencyStatus.value.fiatAmount ?: BigDecimal.ZERO
return if (state.isFiatValue) {
toBigDecimal() == currencyFiatAmount
val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val currencyFiatAmount = cryptoCurrencyStatus.value.fiatAmount ?: BigDecimal.ZERO
return if (amountTextField.isFiatValue) {
parseToBigDecimal(amountTextField.fiatAmount.decimals) == currencyFiatAmount
} else {
toBigDecimal() == currencyCryptoAmount
parseToBigDecimal(amountTextField.cryptoAmount.decimals) == currencyCryptoAmount
}
}
private fun String.trim(): String {
var trimmedValue = this
if (length > 1 && firstOrNull() == '0' && get(1).isDigit()) trimmedValue = drop(1)
return trimmedValue.replace(TRIM_REGEX.toRegex(), ".")
}
companion object {
private val DEFAULT_VALUE = NumberFormat.getInstance().format(0.00)
private const val TRIM_REGEX = "[.,]"
}
}

View file

@ -4,31 +4,47 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.convertToAmount
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import java.text.NumberFormat
import java.math.BigDecimal
private const val FIAT_DECIMALS = 2
internal class SendAmountFieldConverter(
private val clickIntents: SendClickIntents,
) : Converter<Unit, SendTextField.Amount> {
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
) : Converter<Unit, SendTextField.AmountField> {
override fun convert(value: Unit): SendTextField.Amount {
return SendTextField.Amount(
override fun convert(value: Unit): SendTextField.AmountField {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
return SendTextField.AmountField(
value = "",
fiatValue = DEFAULT_VALUE,
fiatValue = "",
onValueChange = clickIntents::onAmountValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number,
),
placeholder = TextReference.Str(DEFAULT_VALUE),
isFiatValue = false,
cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrencyStatus.currency),
fiatAmount = getAppCurrencyAmount(appCurrencyProvider()),
isError = false,
error = TextReference.Res(R.string.swapping_insufficient_funds),
)
}
companion object {
private val DEFAULT_VALUE = NumberFormat.getInstance().format(0.00)
}
private fun getAppCurrencyAmount(appCurrency: AppCurrency) = Amount(
currencySymbol = appCurrency.symbol,
value = BigDecimal.ZERO,
decimals = FIAT_DECIMALS,
type = AmountType.FiatType(appCurrency.code),
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.state.fields
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.tokens.model.Amount
@Immutable
internal sealed class SendTextField {
@ -16,14 +17,13 @@ internal sealed class SendTextField {
/** Keyboard options */
abstract val keyboardOptions: KeyboardOptions
// /** Placeholder (hint) */
// abstract val placeholder: TextReference
data class Amount(
data class AmountField(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
val placeholder: TextReference,
val cryptoAmount: Amount,
val fiatAmount: Amount,
val isFiatValue: Boolean,
val fiatValue: String,
val isError: Boolean,
val error: TextReference,
@ -53,6 +53,10 @@ internal sealed class SendTextField {
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
val symbol: String?,
val decimals: Int,
val title: TextReference,
val footer: TextReference,
val label: TextReference? = null,
) : SendTextField()
}

View file

@ -0,0 +1,73 @@
package com.tangem.features.send.impl.presentation.ui
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendAlertState
import com.tangem.features.send.impl.presentation.state.SendEvent
@Composable
internal fun SendEventEffect(event: StateEvent<SendEvent>, snackbarHostState: SnackbarHostState) {
val resources = LocalContext.current.resources
var alertConfig by remember { mutableStateOf<SendAlertState?>(value = null) }
alertConfig?.let {
SendAlert(state = it, onDismiss = { alertConfig = null })
}
EventEffect(
event = event,
onTrigger = { value ->
when (value) {
is SendEvent.ShowSnackBar -> {
snackbarHostState.showSnackbar(message = value.text.resolveReference(resources))
}
is SendEvent.ShowAlert -> {
alertConfig = value.alert
}
}
},
)
}
@Composable
internal fun SendAlert(state: SendAlertState, onDismiss: () -> Unit) {
val confirmButton: DialogButton
val dismissButton: DialogButton?
val onActionClick = state.onConfirmClick
if (onActionClick != null) {
confirmButton = DialogButton(
title = state.confirmButtonText.resolveReference(),
onClick = {
onActionClick()
onDismiss()
},
)
dismissButton = DialogButton(
title = stringResource(id = R.string.common_cancel),
onClick = onDismiss,
)
} else {
confirmButton = DialogButton(
title = state.confirmButtonText.resolveReference(),
onClick = onDismiss,
)
dismissButton = null
}
BasicDialog(
message = state.message.resolveReference(),
confirmButton = confirmButton,
onDismissDialog = onDismiss,
title = state.title?.resolveReference(),
dismissButton = dismissButton,
)
}

View file

@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -28,6 +30,7 @@ import com.tangem.features.send.impl.presentation.ui.send.SendContent
internal fun SendScreen(uiState: SendUiState) {
val currentState = uiState.currentState.collectAsStateWithLifecycle()
val isSuccess = uiState.sendState.isSuccess
val snackbarHostState = remember { SnackbarHostState() }
BackHandler { uiState.clickIntents.onBackClick() }
Column(
modifier = Modifier
@ -65,6 +68,11 @@ internal fun SendScreen(uiState: SendUiState) {
)
SendNavigationButtons(uiState)
}
SendEventEffect(
event = uiState.event,
snackbarHostState = snackbarHostState,
)
}
@Composable

View file

@ -3,48 +3,49 @@ package com.tangem.features.send.impl.presentation.ui.amount
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomCenter
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Alignment.Companion.TopCenter
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.defaultFormat
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
@Composable
internal fun ColumnScope.AmountField(
sendField: SendTextField.Amount,
cryptoSymbol: String,
fiatSymbol: String,
isFiat: Boolean,
) {
val value = if (isFiat) sendField.fiatValue else sendField.value
val secondaryValue = if (!isFiat) sendField.fiatValue else sendField.value
val symbol = if (isFiat) fiatSymbol else cryptoSymbol
val secondarySymbol = if (!isFiat) fiatSymbol else cryptoSymbol
internal fun AmountField(sendField: SendTextField.AmountField, isFiat: Boolean) {
val decimalFormat = rememberDecimalFormat()
val (primaryValue, secondaryValue) = if (isFiat) {
sendField.fiatValue to sendField.value
} else {
sendField.value to sendField.fiatValue
}
AmountFieldInner(
value = value,
placeholder = sendField.placeholder,
symbol = symbol,
val (primaryAmount, secondaryAmount) = if (!isFiat) {
sendField.cryptoAmount to sendField.fiatAmount
} else {
sendField.fiatAmount to sendField.cryptoAmount
}
AmountTextField(
value = primaryValue,
decimals = primaryAmount.decimals,
symbol = primaryAmount.currencySymbol,
onValueChange = sendField.onValueChange,
keyboardOptions = sendField.keyboardOptions,
textStyle = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
placeholderAlignment = TopCenter,
modifier = Modifier
.align(CenterHorizontally)
.padding(
top = TangemTheme.dimens.spacing24,
start = TangemTheme.dimens.spacing12,
@ -54,15 +55,15 @@ internal fun ColumnScope.AmountField(
Box(
modifier = Modifier
.align(CenterHorizontally)
.padding(
top = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
) {
val text = "${secondaryValue.ifEmpty { decimalFormat.defaultFormat() }} ${secondaryAmount.currencySymbol}"
Text(
text = "$secondaryValue $secondarySymbol",
text = text,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
@ -80,47 +81,6 @@ internal fun ColumnScope.AmountField(
}
}
@Composable
private fun AmountFieldInner(
value: String,
placeholder: TextReference,
symbol: String,
onValueChange: (String) -> Unit,
keyboardOptions: KeyboardOptions,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier
.focusRequester(focusRequester)
.background(TangemTheme.colors.background.action),
textStyle = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
keyboardOptions = keyboardOptions,
singleLine = true,
visualTransformation = AmountVisualTransformation(symbol),
decorationBox = { innerTextField ->
Box {
if (value.isBlank()) {
Text(
text = "${placeholder.resolveReference()} $symbol",
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.disabled,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopCenter),
)
}
innerTextField()
}
},
)
}
@Composable
private fun AmountFieldError(isError: Boolean, error: TextReference, modifier: Modifier = Modifier) {
AnimatedVisibility(

View file

@ -12,12 +12,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.components.currency.tokenicon.TokenIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendStates
@Composable
internal fun AmountFieldContainer(amountState: SendStates.AmountState, modifier: Modifier = Modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxWidth()
.padding(
@ -33,29 +35,24 @@ internal fun AmountFieldContainer(amountState: SendStates.AmountState, modifier:
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing14)
.align(Alignment.CenterHorizontally),
.padding(top = TangemTheme.dimens.spacing14),
)
Text(
text = amountState.walletBalance,
text = amountState.walletBalance.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing2)
.align(Alignment.CenterHorizontally),
.padding(top = TangemTheme.dimens.spacing2),
)
TokenIcon(
state = amountState.tokenIconState,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing32)
.align(Alignment.CenterHorizontally),
.padding(top = TangemTheme.dimens.spacing32),
)
AmountField(
sendField = amountState.amountTextField,
isFiat = amountState.isFiatValue,
cryptoSymbol = amountState.cryptoCurrencyStatus.currency.symbol,
fiatSymbol = amountState.appCurrency.symbol,
isFiat = amountState.amountTextField.isFiatValue,
)
}
}

View file

@ -5,86 +5,64 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.components.inputrow.InputRowEnter
import com.tangem.core.ui.components.inputrow.InputRowEnterInfo
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.components.inputrow.InputRowEnterAmount
import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmount
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
import kotlinx.collections.immutable.ImmutableList
private const val ETHEREUM_UNIT = "GWEI"
@Composable
internal fun SendCustomFeeEthereum(
customValues: ImmutableList<SendTextField.CustomFee>,
selectedFee: FeeType,
symbol: String,
modifier: Modifier = Modifier,
) {
if (selectedFee == FeeType.CUSTOM && customValues.isNotEmpty()) {
val fee = customValues[0]
val gasPrice = customValues[1]
val gasLimit = customValues[2]
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
modifier = modifier,
) {
FooterContainer(
footer = stringResource(R.string.send_max_fee_footer),
) {
InputRowEnterInfo(
text = fee.value,
title = TextReference.Res(R.string.send_max_fee),
info = fee.label,
visualTransformation = AmountVisualTransformation(symbol),
keyboardOptions = fee.keyboardOptions,
onValueChange = fee.onValueChange,
isSingleLine = true,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
FooterContainer(
footer = stringResource(R.string.send_gas_price_footer),
) {
InputRowEnter(
text = gasPrice.value,
title = TextReference.Res(R.string.send_gas_price),
onValueChange = gasPrice.onValueChange,
visualTransformation = AmountVisualTransformation(ETHEREUM_UNIT),
keyboardOptions = fee.keyboardOptions,
isSingleLine = true,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
FooterContainer(
footer = stringResource(R.string.send_gas_limit_footer),
) {
InputRowEnter(
text = gasLimit.value,
title = TextReference.Res(R.string.send_gas_limit),
onValueChange = gasLimit.onValueChange,
keyboardOptions = fee.keyboardOptions,
isSingleLine = true,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
repeat(customValues.size) { index ->
val value = customValues[index]
FooterContainer(
footer = value.footer.resolveReference(),
) {
if (value.label != null) {
InputRowEnterInfoAmount(
text = value.value,
decimals = value.decimals,
symbol = value.symbol,
title = value.title,
info = value.label,
keyboardOptions = value.keyboardOptions,
onValueChange = value.onValueChange,
showDivider = false,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
} else {
InputRowEnterAmount(
text = value.value,
decimals = value.decimals,
title = value.title,
symbol = value.symbol,
onValueChange = value.onValueChange,
keyboardOptions = value.keyboardOptions,
showDivider = false,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
}
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.features.send.impl.presentation.ui.fee
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -16,7 +15,6 @@ import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.res.TangemTheme
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
import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
@ -24,6 +22,7 @@ import kotlinx.collections.immutable.ImmutableList
private const val FEE_SELECTOR_KEY = "FEE_SELECTOR_KEY"
private const val FEE_CUSTOM_KEY = "FEE_CUSTOM_KEY"
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: SendClickIntents) {
if (state == null) return
@ -36,7 +35,6 @@ internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: S
.padding(
horizontal = TangemTheme.dimens.spacing16,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
item(
key = FEE_SELECTOR_KEY,
@ -44,17 +42,15 @@ internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: S
SendSpeedSelector(
state = feeSendState,
clickIntents = clickIntents,
modifier = Modifier.animateItemPlacement(),
)
}
customFee(feeSendState)
notifications(notifications)
customFee(
feeSendState = feeSendState,
cryptoCurrencySymbol = state.cryptoCurrencyStatus.currency.symbol,
)
subtractButton(
feeSendState = feeSendState,
receivedAmount = state.receivedAmount,
isSubtract = state.isSubtract,
isSubtractAvailable = state.isSubtractAvailable,
clickIntents = clickIntents,
)
}
@ -69,10 +65,24 @@ internal fun LazyListScope.notifications(configs: ImmutableList<SendFeeNotificat
itemContent = {
Notification(
config = it.config,
modifier = modifier.animateItemPlacement(),
containerColor = TangemTheme.colors.button.disabled,
modifier = modifier
.padding(top = TangemTheme.dimens.spacing12)
.animateItemPlacement(),
containerColor = when (it) {
is SendFeeNotification.Error.ExceedsBalance,
is SendFeeNotification.Warning.NetworkFeeUnreachable,
-> TangemTheme.colors.background.primary
else -> TangemTheme.colors.button.disabled
},
iconTint = when (it) {
is SendFeeNotification.Informational -> TangemTheme.colors.icon.accent
is SendFeeNotification.Error.ExceedsBalance -> {
if (it.config.buttonsState == null) {
TangemTheme.colors.icon.warning
} else {
null
}
}
else -> null
},
)
@ -81,11 +91,7 @@ internal fun LazyListScope.notifications(configs: ImmutableList<SendFeeNotificat
}
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.customFee(
feeSendState: FeeSelectorState,
cryptoCurrencySymbol: String,
modifier: Modifier = Modifier,
) {
internal fun LazyListScope.customFee(feeSendState: FeeSelectorState, modifier: Modifier = Modifier) {
item(
key = FEE_CUSTOM_KEY,
) {
@ -101,7 +107,7 @@ internal fun LazyListScope.customFee(
SendCustomFeeEthereum(
customValues = customValues,
selectedFee = fee.selectedFee,
symbol = cryptoCurrencySymbol,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
)
}
}
@ -110,31 +116,21 @@ internal fun LazyListScope.customFee(
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.subtractButton(
feeSendState: FeeSelectorState,
receivedAmount: String,
isSubtract: Boolean,
isSubtractAvailable: Boolean,
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
(feeSendState as? FeeSelectorState.Content)?.let { state ->
if (isSubtractAvailable) {
item {
val selectedFeeValue = state.selectedFee
val topPadding = if (selectedFeeValue != FeeType.CUSTOM) {
TangemTheme.dimens.spacing8
} else {
TangemTheme.dimens.spacing0
}
SendSpeedSubtract(
receivingAmount = receivedAmount,
isSubtract = isSubtract,
onSelectClick = clickIntents::onSubtractSelect,
modifier = modifier
.animateItemPlacement()
.padding(
top = topPadding,
bottom = TangemTheme.dimens.spacing12,
),
.padding(vertical = TangemTheme.dimens.spacing12)
.animateItemPlacement(),
)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.send.impl.presentation.ui.fee
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -13,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@ -26,12 +28,19 @@ import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.R
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.ui.common.FooterContainer
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
private val DEFAULT_FEE_OPTIONS = listOf(
R.string.common_fee_selector_option_slow to R.drawable.ic_tortoise_24,
R.string.common_fee_selector_option_market to R.drawable.ic_bird_24,
R.string.common_fee_selector_option_fast to R.drawable.ic_hare_24,
)
@Suppress("LongMethod")
@Composable
internal fun SendSpeedSelector(
@ -50,10 +59,11 @@ internal fun SendSpeedSelector(
.background(TangemTheme.colors.background.action),
) {
when (state) {
FeeSelectorState.Error -> {
SendSpeedSelectorItemError()
}
FeeSelectorState.Loading -> {
SendSpeedSelectorItemLoading()
SendSpeedSelectorItemLoading()
SendSpeedSelectorItemLoading()
}
is FeeSelectorState.Content -> {
when (state.fees) {
@ -84,7 +94,10 @@ internal fun SendSpeedSelector(
onSelect = { clickIntents.onFeeSelectorClick(FeeType.FAST) },
showDivider = state.fees.normal is Fee.Ethereum,
)
if (state.fees.normal is Fee.Ethereum) {
AnimatedVisibility(
visible = state.fees.normal is Fee.Ethereum,
label = "Custom fee appearance animation",
) {
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_custom,
iconRes = R.drawable.ic_edit_24,
@ -114,34 +127,52 @@ internal fun SendSpeedSelector(
@Composable
private fun SendSpeedSelectorItemLoading() {
Row(modifier = Modifier.fillMaxWidth()) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing18,
bottom = TangemTheme.dimens.spacing18,
start = TangemTheme.dimens.spacing12,
)
.size(
width = TangemTheme.dimens.size50,
height = TangemTheme.dimens.size12,
),
)
SpacerWMax()
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing18,
bottom = TangemTheme.dimens.spacing18,
end = TangemTheme.dimens.spacing12,
)
.size(
width = TangemTheme.dimens.size90,
height = TangemTheme.dimens.size12,
),
)
repeat(DEFAULT_FEE_OPTIONS.size) {
val (text, iconRes) = DEFAULT_FEE_OPTIONS[it]
Row(modifier = Modifier.fillMaxWidth()) {
SelectorTitleContent(
titleRes = text,
iconRes = iconRes,
)
SpacerWMax()
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing18,
bottom = TangemTheme.dimens.spacing18,
end = TangemTheme.dimens.spacing12,
)
.size(
width = TangemTheme.dimens.size90,
height = TangemTheme.dimens.size12,
),
)
}
}
}
@Composable
private fun SendSpeedSelectorItemError() {
repeat(DEFAULT_FEE_OPTIONS.size) {
val (text, iconRes) = DEFAULT_FEE_OPTIONS[it]
Row(modifier = Modifier.fillMaxWidth()) {
SelectorTitleContent(
titleRes = text,
iconRes = iconRes,
)
SpacerWMax()
Text(
text = BigDecimalFormatter.EMPTY_BALANCE_SIGN,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing12,
),
)
}
}
}
@ -177,27 +208,11 @@ private fun SendSpeedSelectorItem(
.clickable { onSelect() },
) {
Row(modifier = Modifier.fillMaxWidth()) {
Icon(
painter = painterResource(iconRes),
tint = iconTint,
contentDescription = null,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
)
Text(
text = stringResource(titleRes),
style = textStyle,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing8,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
SelectorTitleContent(
titleRes = titleRes,
iconRes = iconRes,
iconTint = iconTint,
textStyle = textStyle,
)
if (amount != null && symbol != null) {
SelectorValueContent(
@ -220,6 +235,37 @@ private fun SendSpeedSelectorItem(
}
}
@Composable
private fun SelectorTitleContent(
@StringRes titleRes: Int,
@DrawableRes iconRes: Int,
iconTint: Color = TangemTheme.colors.icon.informative,
textStyle: TextStyle = TangemTheme.typography.body2,
) {
Icon(
painter = painterResource(iconRes),
tint = iconTint,
contentDescription = null,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
),
)
Text(
text = stringResource(titleRes),
style = textStyle,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing8,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing14,
),
)
}
@Composable
private fun RowScope.SelectorValueContent(amount: TextReference, symbol: TextReference, textStyle: TextStyle) {
Text(

View file

@ -24,6 +24,7 @@ import androidx.constraintlayout.compose.Visibility
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
@ -49,10 +50,11 @@ fun ListItemWithIcon(
subtitleEndOffset: Int = 0,
@DrawableRes subtitleIconRes: Int? = null,
) {
val hapticFeedback = rememberHapticFeedback(state = title, onAction = onClick)
ConstraintLayout(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.clickable { hapticFeedback() }
.padding(horizontal = TangemTheme.dimens.spacing12),
) {
val (iconRef, titleRef, subtitleRef, subtitleIconRef) = createRefs()

View file

@ -59,7 +59,6 @@ internal fun SendRecipientContent(
placeholder = address.placeholder,
onValueChange = address.onValueChange,
onPasteClick = clickIntents::onRecipientAddressValueChange,
singleLine = true,
isError = isError,
isLoading = isValidating,
error = address.error,

View file

@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.ui.recipient
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -55,6 +56,7 @@ internal fun TextFieldWithPaste(
placeholder = placeholder,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing6),
)
}

View file

@ -17,16 +17,17 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.core.ui.components.inputrow.InputRowDefault
import com.tangem.core.ui.components.inputrow.InputRowImage
import com.tangem.core.ui.components.inputrow.InputRowRecipientDefault
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.BigDecimalFormatter.formatCryptoAmount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.state.SendStates
@ -61,7 +62,7 @@ internal fun SendContent(uiState: SendUiState) {
AnimatedVisibility(visible = !isSuccess) {
FromWallet(
walletName = amountState.walletName,
walletBalance = amountState.walletBalance,
walletBalance = amountState.walletBalance.resolveReference(),
)
}
AmountBlock(
@ -122,13 +123,14 @@ private fun AmountBlock(amountState: SendStates.AmountState, isSuccess: Boolean,
val amount = amountState.amountTextField
val cryptoAmount = formatCryptoAmount(
cryptoCurrency = amountState.cryptoCurrencyStatus.currency,
cryptoAmount = amount.value.toBigDecimalOrDefault(),
cryptoAmount = amount.cryptoAmount.value,
cryptoCurrency = amount.cryptoAmount.currencySymbol,
decimals = amount.cryptoAmount.decimals,
)
val fiatAmount = BigDecimalFormatter.formatFiatAmount(
fiatAmount = amount.fiatValue.toBigDecimalOrDefault(),
fiatCurrencyCode = amountState.appCurrency.code,
fiatCurrencySymbol = amountState.appCurrency.symbol,
fiatAmount = amount.fiatAmount.value,
fiatCurrencyCode = (amount.fiatAmount.type as AmountType.FiatType).code,
fiatCurrencySymbol = amount.fiatAmount.currencySymbol,
)
InputRowImage(
title = TextReference.Res(R.string.send_amount_label),
@ -197,7 +199,10 @@ internal fun LazyListScope.notifications(configs: ImmutableList<SendNotification
Notification(
config = it.config,
modifier = modifier.animateItemPlacement(),
containerColor = TangemTheme.colors.button.disabled,
containerColor = when (it) {
is SendNotification.Warning.HighFeeError -> TangemTheme.colors.background.action
else -> TangemTheme.colors.button.disabled
},
iconTint = when (it) {
is SendNotification.Error -> TangemTheme.colors.icon.warning
is SendNotification.Warning -> null

View file

@ -4,6 +4,7 @@ import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.impl.presentation.state.fee.FeeType
@Suppress("TooManyFunctions")
interface SendClickIntents {
fun popBackStack()
@ -16,6 +17,8 @@ interface SendClickIntents {
fun onQrCodeScanClick()
fun onFailedTxEmailClick(errorMessage: String)
fun onTokenDetailsClick(userWalletId: UserWalletId, currency: CryptoCurrency)
// region Amount
@ -33,6 +36,8 @@ interface SendClickIntents {
// endregion
// region Fee
fun feeReload()
fun onFeeSelectorClick(feeType: FeeType)
fun onCustomFeeValueChange(index: Int, value: String)
@ -50,5 +55,9 @@ interface SendClickIntents {
fun showFee()
fun onExploreClick(txUrl: String)
fun onAmountReduceClick(reducedAmount: String)
fun onAmountReduceIgnoreClick()
// endregion
}

View file

@ -5,17 +5,23 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import androidx.paging.PagingData
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchain.blockchains.xrp.XrpAddressService
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.GetNetworkCoinStatusUseCase
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
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.SendTransactionUseCase
@ -31,23 +37,14 @@ import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.send.impl.navigation.InnerSendRouter
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.SendNotificationFactory
import com.tangem.features.send.impl.presentation.state.SendStateFactory
import com.tangem.features.send.impl.presentation.state.SendUiState
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.fee.getFee
import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.utils.Provider
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.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.properties.Delegates
@ -58,6 +55,7 @@ internal class SendViewModel @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase,
private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase,
private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
@ -70,8 +68,11 @@ internal class SendViewModel @Inject constructor(
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
private val parseSharedAddressUseCase: ParseSharedAddressUseCase,
private val walletManagersFacade: WalletManagersFacade,
private val reduxStateHolder: ReduxStateHolder,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
validateWalletMemoUseCase: ValidateWalletMemoUseCase,
getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver, SendClickIntents {
@ -93,23 +94,48 @@ internal class SendViewModel @Inject constructor(
userWalletProvider = Provider { userWallet },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
validateWalletMemoUseCase = validateWalletMemoUseCase,
getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase,
)
private val feeStateFactory = FeeStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
)
private val eventStateFactory = SendEventStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
feeStateFactory = feeStateFactory,
)
private val feeNotificationFactory = FeeNotificationFactory(
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
clickIntents = this,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
)
private val sendNotificationFactory = SendNotificationFactory(
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
walletManagersFacade = walletManagersFacade,
clickIntents = this,
)
// todo convert to StateFlow
var uiState: SendUiState by mutableStateOf(stateFactory.getInitialState())
private set
private var userWallet: UserWallet by Delegates.notNull()
private var isAmountSubtractAvailable: Boolean = false
private var coinCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
@ -118,11 +144,14 @@ internal class SendViewModel @Inject constructor(
private var feeJobHolder = JobHolder()
private var addressValidationJobHolder = JobHolder()
private var sendNotificationsJobHolder = JobHolder()
private var feeNotificationsJobHolder = JobHolder()
private var qrScannerJobHolder = JobHolder()
private var sendIdleTimer = 0L
override fun onCreate(owner: LifecycleOwner) {
subscribeOnCurrencyStatusUpdates(owner)
getFee()
onStateActive()
}
fun setRouter(router: InnerSendRouter, stateRouter: StateRouter) {
@ -136,10 +165,13 @@ internal class SendViewModel @Inject constructor(
getUserWalletUseCase(userWalletId).fold(
ifRight = { wallet ->
userWallet = wallet
checkIfSubtractAvailable()
getCurrenciesStatusUpdates(owner, wallet)
},
ifLeft = {
// todo add error handling [[REDACTED_JIRA]]
uiState = eventStateFactory.getGenericErrorState(
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
return@launch
},
)
@ -280,37 +312,16 @@ internal class SendViewModel @Inject constructor(
}
}
private fun getFee() {
viewModelScope.launch(dispatchers.main) {
uiState.currentState
.filter { it == SendUiStateType.Fee }
.onEach {
val amountState = uiState.amountState ?: return@onEach
val recipientState = uiState.recipientState ?: return@onEach
val amount = amountState.amountTextField.value.toBigDecimal()
uiState = stateFactory.onFeeOnLoadingState()
getFeeUseCase.invoke(
amount = amount,
destination = recipientState.addressTextField.value,
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
)
.conflate()
.distinctUntilChanged()
.onEach { maybeFee ->
maybeFee.fold(
ifRight = {
uiState = stateFactory.onFeeOnLoadedState(it)
},
ifLeft = {
// todo add error handling [[REDACTED_JIRA]]
},
)
}
.launchIn(viewModelScope)
}.launchIn(viewModelScope)
}.saveIn(feeJobHolder)
private fun onStateActive() {
uiState.currentState
.onEach {
when (it) {
SendUiStateType.Fee -> loadFee()
SendUiStateType.Send -> sendIdleTimer = System.currentTimeMillis()
else -> Unit
}
}
.launchIn(viewModelScope)
}
private fun updateNotifications() {
@ -323,6 +334,16 @@ internal class SendViewModel @Inject constructor(
.saveIn(sendNotificationsJobHolder)
}
private fun updateFeeNotifications() {
feeNotificationFactory.create()
.conflate()
.distinctUntilChanged()
.onEach { uiState = feeStateFactory.getFeeNotificationState(notifications = it) }
.flowOn(dispatchers.io)
.launchIn(viewModelScope)
.saveIn(feeNotificationsJobHolder)
}
// region screen state navigation
override fun popBackStack() = stateRouter.popBackStack()
override fun onBackClick() = stateRouter.onBackClick()
@ -331,6 +352,10 @@ internal class SendViewModel @Inject constructor(
override fun onQrCodeScanClick() = innerRouter.openQrCodeScanner(cryptoCurrency.network.name)
override fun onFailedTxEmailClick(errorMessage: String) {
reduxStateHolder.dispatch(LegacyAction.SendEmailTransactionFailed(errorMessage))
}
override fun onTokenDetailsClick(userWalletId: UserWalletId, currency: CryptoCurrency) =
innerRouter.openTokenDetails(userWalletId, currency)
// endregion
@ -346,12 +371,15 @@ internal class SendViewModel @Inject constructor(
override fun onMaxValueClick() {
val amountState = uiState.amountState ?: return
val amount = if (amountState.isFiatValue) {
amountState.cryptoCurrencyStatus.value.fiatAmount
val amountTextField = amountState.amountTextField
val (amount, decimals) = if (amountTextField.isFiatValue) {
cryptoCurrencyStatus.value.fiatAmount to amountTextField.fiatAmount.decimals
} else {
amountState.cryptoCurrencyStatus.value.amount
cryptoCurrencyStatus.value.amount to amountTextField.cryptoAmount.decimals
}
if (amount != null && !amount.isZero()) {
onAmountValueChange(amount.parseBigDecimal(decimals))
}
onAmountValueChange(amount?.toPlainString() ?: DEFAULT_VALUE)
}
// endregion
@ -415,16 +443,56 @@ internal class SendViewModel @Inject constructor(
// endregion
// region fee
override fun feeReload() = loadFee()
override fun onFeeSelectorClick(feeType: FeeType) {
uiState = stateFactory.onFeeSelectedState(feeType)
uiState = feeStateFactory.onFeeSelectedState(feeType)
updateFeeNotifications()
}
override fun onCustomFeeValueChange(index: Int, value: String) {
uiState = stateFactory.onCustomFeeValueChange(index, value)
uiState = feeStateFactory.onCustomFeeValueChange(index, value)
updateFeeNotifications()
}
override fun onSubtractSelect(value: Boolean) {
uiState = stateFactory.onSubtractSelect(value)
uiState = feeStateFactory.onSubtractSelect(value)
updateFeeNotifications()
}
private fun loadFee() {
viewModelScope.launch(dispatchers.main) {
uiState = feeStateFactory.onFeeOnLoadingState()
uiState = callFeeUseCase()?.fold(
ifRight = { fees ->
feeStateFactory.onFeeOnLoadedState(fees, isAmountSubtractAvailable)
},
ifLeft = {
feeStateFactory.onFeeOnErrorState()
},
) ?: feeStateFactory.onFeeOnErrorState()
updateFeeNotifications()
}.saveIn(feeJobHolder)
}
private suspend fun checkIfSubtractAvailable() {
isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(userWalletId, cryptoCurrency).fold(
ifRight = { it },
ifLeft = { false },
)
}
private suspend fun callFeeUseCase(): Either<GetFeeError, TransactionFee>? {
val amountState = uiState.amountState ?: return null
val recipientState = uiState.recipientState ?: return null
val amount = amountState.amountTextField.cryptoAmount.value ?: return null
return getFeeUseCase.invoke(
amount = amount,
destination = recipientState.addressTextField.value,
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
)
}
// endregion
@ -433,68 +501,145 @@ internal class SendViewModel @Inject constructor(
val sendState = uiState.sendState
if (sendState.isSuccess) popBackStack()
uiState = stateFactory.getSendingStateUpdate(true)
viewModelScope.launch(dispatchers.io) { verifyAndSendTransaction() }
uiState = stateFactory.getSendingStateUpdate(isSending = true)
if (System.currentTimeMillis() - sendIdleTimer < CHECK_FEE_UPDATE_DELAY) {
verifyAndSendTransaction()
} else {
onCheckFeeUpdate()
}
sendIdleTimer = System.currentTimeMillis()
}
override fun showAmount() = stateRouter.showAmount(isFromSend = true)
override fun showAmount() = stateRouter.showAmount()
override fun showRecipient() = stateRouter.showRecipient(isFromSend = true)
override fun showRecipient() = stateRouter.showRecipient()
override fun showFee() = stateRouter.showFee(isFromSend = true)
override fun showFee() = stateRouter.showFee()
override fun onExploreClick(txUrl: String) = innerRouter.openUrl(txUrl)
private suspend fun verifyAndSendTransaction() {
override fun onAmountReduceClick(reducedAmount: String) {
uiState = stateFactory.getOnAmountValueChange(reducedAmount)
uiState = sendNotificationFactory.dismissHighFeeWarningState()
loadFee()
}
override fun onAmountReduceIgnoreClick() {
uiState = sendNotificationFactory.dismissHighFeeWarningState()
}
private fun verifyAndSendTransaction() {
val recipient = uiState.recipientState?.addressTextField?.value ?: return
val feeState = uiState.feeState ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val memo = uiState.recipientState?.memoTextField?.value
val fee = feeSelectorState.getFee()
val amountValue = uiState.amountState?.amountTextField?.cryptoAmount?.value ?: return
val amountToSend = if (feeState.isSubtract && isAmountSubtractAvailable) {
feeState.receivedAmountValue
} else {
amountValue
}
val amountToSend = feeState.receivedAmountValue.convertToAmount(cryptoCurrency)
viewModelScope.launch(dispatchers.main) {
createTransactionUseCase(
amount = amountToSend.convertToAmount(cryptoCurrency),
fee = fee,
memo = memo,
destination = recipient,
userWalletId = userWalletId,
network = cryptoCurrency.network,
).fold(
ifLeft = {
Timber.e(it)
uiState = eventStateFactory.getGenericErrorState(
error = it,
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
},
ifRight = { txData ->
sendTransaction(txData)
},
)
}
}
// todo add error handling [[REDACTED_JIRA]]
// val transactionErrors = walletManagersFacade.validateTransaction(
// amount = amountToSend,
// fee = fee.amount,
// userWalletId = userWalletId,
// network = cryptoCurrency.network,
// )
createTransactionUseCase(
amount = amountToSend,
fee = fee,
memo = memo,
destination = recipient,
userWalletId = userWalletId,
private suspend fun sendTransaction(txData: TransactionData) {
sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = cryptoCurrency.network,
).fold(
ifLeft = {
Timber.e(it)
// todo add error handling [[REDACTED_JIRA]]
},
ifRight = { txData ->
sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = cryptoCurrency.network,
).fold(
ifLeft = {
uiState = stateFactory.getSendingStateUpdate(false)
// todo add error handling [[REDACTED_JIRA]]
},
ifRight = {
uiState = stateFactory.getTransactionSendState(txData)
},
ifLeft = { error ->
uiState = stateFactory.getSendingStateUpdate(isSending = false)
uiState = eventStateFactory.getSendTransactionErrorState(
error = error,
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
},
ifRight = {
uiState = stateFactory.getSendingStateUpdate(isSending = false)
uiState = stateFactory.getTransactionSendState(txData)
scheduleBalanceUpdate()
},
)
}
private fun scheduleBalanceUpdate() {
viewModelScope.launch(dispatchers.io) {
delay(BALANCE_UPDATE_DELAY)
fetchCurrencyStatusUseCase.invoke(
userWalletId = userWalletId,
id = cryptoCurrency.id,
refresh = true,
)
}
}
private fun onCheckFeeUpdate() {
val isSending = uiState.sendState.isSending
val isSuccess = uiState.sendState.isSuccess
val noErrorNotifications = uiState.sendState.notifications.none { it is SendNotification.Error }
if (!isSending && !isSuccess && noErrorNotifications) {
viewModelScope.launch(dispatchers.main) {
val feeUpdatedState = callFeeUseCase()?.fold(
ifRight = {
uiState = stateFactory.getSendingStateUpdate(isSending = false)
eventStateFactory.getFeeUpdatedAlert(
fee = it,
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
onFeeNotIncreased = {
uiState = stateFactory.getSendingStateUpdate(isSending = true)
verifyAndSendTransaction()
},
)
},
ifLeft = {
uiState = stateFactory.getSendingStateUpdate(isSending = false)
eventStateFactory.getGenericErrorState(
error = (it as? GetFeeError.DataError)?.cause,
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
},
)
uiState = if (feeUpdatedState != null) {
feeUpdatedState
} else {
uiState = stateFactory.getSendingStateUpdate(isSending = false)
eventStateFactory.getGenericErrorState(
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
}
}
}
}
// endregion
companion object {
private const val XRP_X_ADDRESS = 'X'
private const val DEFAULT_VALUE = "0.00"
private const val CHECK_FEE_UPDATE_DELAY = 60_000L
private const val BALANCE_UPDATE_DELAY = 10_000L
}
}

View file

@ -11,7 +11,7 @@ internal class ErrorsDataConverter(
private val jsonAdapter: JsonAdapter<ExpressErrorResponse>,
) : Converter<String, DataError> {
@Suppress("MagicNumber")
@Suppress("MagicNumber", "CyclomaticComplexMethod")
override fun convert(value: String): DataError {
try {
val error = jsonAdapter.fromJson(value)?.error ?: return DataError.UnknownError
@ -23,6 +23,7 @@ internal class ErrorsDataConverter(
2230 -> DataError.ExchangeProviderNotAvailableError(code = error.code)
2240 -> DataError.ExchangeNotPossibleError(code = error.code)
2250 -> tryParseExchangeTooSmallAmountError(error = error)
2251 -> tryParseExchangeTooBigAmountError(error = error)
2260 -> tryParseExchangeNotEnoughAllowanceError(error = error)
2270 -> DataError.ExchangeNotEnoughBalanceError(code = error.code)
2280 -> DataError.ExchangeInvalidAddressError(code = error.code)
@ -44,6 +45,16 @@ internal class ErrorsDataConverter(
)
}
private fun tryParseExchangeTooBigAmountError(error: ExpressError): DataError {
val minAmount = error.value?.maxAmount ?: return DataError.UnknownErrorWithCode(error.code)
val decimals = error.value?.decimals ?: return DataError.UnknownErrorWithCode(error.code)
return DataError.ExchangeTooBigAmountError(
code = error.code,
amount = createFromAmountWithOffset(minAmount, decimals),
)
}
private fun tryParseExchangeNotEnoughAllowanceError(error: ExpressError): DataError {
val currentAllowance = error.value?.currentAllowance ?: return DataError.UnknownErrorWithCode(error.code)

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