diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 85ed2fdfb3..834e2bdd48 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index c31c62fa66..4ef519edbd 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -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 @@ -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( diff --git a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt index 81d40b5307..9ad974cdcd 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt @@ -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) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index 336c1129ed..b439d53019 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -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) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 5429ab3030..456bf93702 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -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, ) } diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/BuyCurrencyIntentHandler.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/BuyCurrencyIntentHandler.kt deleted file mode 100644 index 31a7657c79..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/BuyCurrencyIntentHandler.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/SellCurrencyIntentHandler.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/SellCurrencyIntentHandler.kt deleted file mode 100644 index 52e8e12a33..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/handlers/SellCurrencyIntentHandler.kt +++ /dev/null @@ -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" - // } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt index b1db79189b..36123cf1a9 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt @@ -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, diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt index 6bd651f70c..b1839fae93 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -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 diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt index ea8a552360..d3e2fd0694 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt @@ -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?, diff --git a/core/deep-links/.gitignore b/core/deep-links/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/core/deep-links/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/deep-links/build.gradle.kts b/core/deep-links/build.gradle.kts new file mode 100644 index 0000000000..9d43ab04df --- /dev/null +++ b/core/deep-links/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/core/deep-links/global/.gitignore b/core/deep-links/global/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/core/deep-links/global/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/deep-links/global/build.gradle.kts b/core/deep-links/global/build.gradle.kts new file mode 100644 index 0000000000..ac47c5ac3d --- /dev/null +++ b/core/deep-links/global/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/BuyCurrencyDeepLink.kt b/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/BuyCurrencyDeepLink.kt new file mode 100644 index 0000000000..286f8f8c73 --- /dev/null +++ b/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/BuyCurrencyDeepLink.kt @@ -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) { + onReceive() + } +} \ No newline at end of file diff --git a/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/SellCurrencyDeepLink.kt b/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/SellCurrencyDeepLink.kt new file mode 100644 index 0000000000..5d9047a83b --- /dev/null +++ b/core/deep-links/global/src/main/kotlin/com/tangem/core/deeplink/global/SellCurrencyDeepLink.kt @@ -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) { + 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, + ) +} \ No newline at end of file diff --git a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeepLink.kt b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeepLink.kt new file mode 100644 index 0000000000..0b85327f28 --- /dev/null +++ b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeepLink.kt @@ -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) +} \ No newline at end of file diff --git a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeepLinksRegistry.kt b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeepLinksRegistry.kt new file mode 100644 index 0000000000..beaeb5f0d2 --- /dev/null +++ b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/DeepLinksRegistry.kt @@ -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) + + /** + * Unregisters the given [deepLinks]. + */ + fun unregister(deepLinks: Collection) + + /** + * Unregisters the given [deepLink]. + */ + fun unregister(deepLink: DeepLink) + + /** + * Unregisters deep links with the given [ids]. + * */ + fun unregisterByIds(ids: Collection) + + /** + * 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) + + /** + * Registers the [deepLinks] and ensures that they are unregistered when the [ViewModel] is closed. + */ + fun registerWithViewModel(viewModel: ViewModel, deepLinks: Collection) +} \ No newline at end of file diff --git a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/di/DeepLinksModule.kt b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/di/DeepLinksModule.kt new file mode 100644 index 0000000000..4398db12a2 --- /dev/null +++ b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/di/DeepLinksModule.kt @@ -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() + } +} \ No newline at end of file diff --git a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt new file mode 100644 index 0000000000..bf84d3e382 --- /dev/null +++ b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt @@ -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 = 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) { + 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) { + 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) { + registries = registries.filter { it.id !in ids } + + Timber.d( + """ + Unregistered deep links + |- Registries: $registries + """.trimIndent(), + ) + } + + override fun registerWithLifecycle(owner: LifecycleOwner, deepLinks: Collection) { + val observer = DeepLinksLifecycleObserver(deepLinksRegistry = this, deepLinks) + owner.lifecycle.addObserver(observer) + } + + override fun registerWithViewModel(viewModel: ViewModel, deepLinks: Collection) { + 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 { + val params = mutableMapOf() + + 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 + } +} \ No newline at end of file diff --git a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/utils/DeepLinksLifecycleObserver.kt b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/utils/DeepLinksLifecycleObserver.kt new file mode 100644 index 0000000000..ae1580e17e --- /dev/null +++ b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/utils/DeepLinksLifecycleObserver.kt @@ -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, +) : DefaultLifecycleObserver { + + override fun onResume(owner: LifecycleOwner) { + deepLinksRegistry.register(deepLinks) + } + + override fun onPause(owner: LifecycleOwner) { + deepLinksRegistry.unregister(deepLinks) + } +} \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings-blockchain.xml b/core/res/src/main/res/values-de/strings-blockchain.xml index d438a29cd8..82986f8529 100644 --- a/core/res/src/main/res/values-de/strings-blockchain.xml +++ b/core/res/src/main/res/values-de/strings-blockchain.xml @@ -1,6 +1,7 @@ Erhalt der Gebühr fehlgeschlagen + Senden Sie Geld an diese Andresse um ein Konto zu erstellen Minimaler Betrag ist %s Restbestand zu klein Falsche Gebühr diff --git a/core/res/src/main/res/values-fr/strings-blockchain.xml b/core/res/src/main/res/values-fr/strings-blockchain.xml index 0f4d6dccec..97b9e7c7c7 100644 --- a/core/res/src/main/res/values-fr/strings-blockchain.xml +++ b/core/res/src/main/res/values-fr/strings-blockchain.xml @@ -1,6 +1,7 @@ Échec de réception des commissions + Pour créer un compte, envoyez des fonds monétaires à cette adresse Le montant minimal est de %s Le reste est trop petit Commission non valide diff --git a/core/res/src/main/res/values-it/strings-blockchain.xml b/core/res/src/main/res/values-it/strings-blockchain.xml index 338c03f0da..bedc136ed0 100644 --- a/core/res/src/main/res/values-it/strings-blockchain.xml +++ b/core/res/src/main/res/values-it/strings-blockchain.xml @@ -1,6 +1,7 @@ Impossibile ottenere la commissione + Per creare un account, invia fondi a questo indirizzo L\'importo minimo è di %s L\'importo residuo è molto basso Commissione non valida diff --git a/core/res/src/main/res/values-ru/strings-blockchain.xml b/core/res/src/main/res/values-ru/strings-blockchain.xml index 0744b44f6b..c58315db3f 100644 --- a/core/res/src/main/res/values-ru/strings-blockchain.xml +++ b/core/res/src/main/res/values-ru/strings-blockchain.xml @@ -6,8 +6,8 @@ Из-за ограничений %1$s в одну транзакцию может поместиться только %2$d UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму. Недостаточно средств для совершения транзакции. Пожалуйста, пополните свой аккаунт. Произошла ошибка. Код: %s. - Из-за ограничений Kaspa в одну транзакцию может поместиться только %1$d UTXO. Это означает, что вы можете отправить только %2$s или меньше. Вам нужно уменьшить сумму. - Чтобы использовать сеть %1$s, вы должны оплатить резерв аккаунта (%2$s %3$s), который блокируется и не используется в вашем балансе. + Для создания аккаунта отправьте средства на этот адрес + Чтобы использовать сеть %1$s, вы должны оплатить резерв аккаунта (%2$s %3$s), который блокируется и не используется в вашем балансе Аккаунт получателя не активирован. Отправьте %s или более для активации аккаунта. Минимальная сумма: %s Сдача слишком мала diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 12777954f8..d5cb28c7c0 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -136,6 +136,7 @@ Перевод Я понял Недоступно + Произошла ошибка. Пожалуйста, попробуйте снова. Да Адрес контракта скопирован! Доступные сети @@ -231,6 +232,7 @@ Провайдер Лучший курс Доступно с %s + Доступно до %s Недоступно для этой пары Требуется разрешение Условиями использования @@ -448,8 +450,9 @@ Сумма Вычесть из суммы отправки Сумма к получению %s + Поддержка Транзакция не выполнена - Причина: %1$s\Код:%2$s + Причина: %1$s\nКод: %2$s %1$s в %2$s Адрес Код назначения @@ -477,8 +480,10 @@ Недостаточно средств Размер комиссии превышает баланс сети. Для продолжения необходимо пополнить баланс сети. Комиссия превышает баланс - Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01. Увеличение комиссии + Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01. + Оставить %s XTZ + Отправить все Установлена высокая комиссия Сумма комиссии в %s раз превышает рекомендованную. Убедитесь, что указанная комиссия верна. Включенная комиссия превышает сумму перевода, что приводит к отрицательному значению @@ -528,7 +533,6 @@ Подтвердить Ошибка: %s Вы отправляете - Произошла ошибка. Пожалуйста, попробуйте еще раз. Дать разрешение Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму. Недостаточно средств @@ -690,8 +694,9 @@ Чтобы совершить транзакцию, вам необходимо внести немного %1$s %2$s Невозможно покрыть комиссию %s Cервис временно недоступен - Пожалуйста, измените сумму для обмена + Пожалуйста, измените сумму для обмена Сумма для обмена должна быть не менее %s + Сумма для обмена должна быть не более %s Возможно, данная карта - образец или подделка Ошибка проверки подлинности На этой карте осталось всего %s подписей. Вам следует вывести все ваши средства. diff --git a/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml b/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml index 607268ec3a..eb43a8cf6f 100644 --- a/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml +++ b/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml @@ -4,6 +4,7 @@ 遺留資產 獲取費用失敗 由於 Kaspa 的限制,只有%1$d UTXO 可以放入單次交易中。這意味著您只能發送%2$s或更少數量。您需要減少數量。 + 要創建帳戶,請將資金發送到此地址 目標帳戶未激活。發送 %s 或更多以激活帳戶 最小數量是 %s 更動太小 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index c7ef19cf31..a1d2ee341a 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -327,7 +327,6 @@ 批准被視為所有去中心化交易所的行業標準,並保護您的錢包在未經您許可的情況下不被智能合約訪問。按照設計,智能合約無法訪問您的代幣,除非您從您的終端批准訪問。通過“解鎖”您的代幣,您將獲得 1inch 智能合約使用您的資產的權限。網絡的礦工將獲得Gas Fee(由您支付)作為補償,以在區塊鏈上記錄此操作。一旦獲得許可,您就可以交易您的代幣。 批准 錯誤: %s - 有錯誤。請再試一遍 賦予權限 在此代幣交換的數量將對價格產生重大影響,並降低您收到的數量 餘額不足 diff --git a/core/res/src/main/res/values/strings-blockchain.xml b/core/res/src/main/res/values/strings-blockchain.xml index bb28ad284c..2aeb56c771 100644 --- a/core/res/src/main/res/values/strings-blockchain.xml +++ b/core/res/src/main/res/values/strings-blockchain.xml @@ -7,7 +7,8 @@ Not enough funds for the transaction. Please top up your account. An error occurred. Code: %s. 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. - To use the %1$s network, you must pay the account reserve (%2$s %3$s), which locks up and hides that amount indefinitely. + To create account send funds to this address + To use the %1$s network, you must pay the account reserve (%2$s %3$s), which locks up and hides that amount indefinitely Destination account is not active. Send %s or more to activate the account. Minimum amount is %s Change is too small diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 585432c7dd..82870064e4 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -135,6 +135,7 @@ Transfer I understand Unreachable + There was an error. Please try again. Yes Contract address copied! Available networks @@ -234,6 +235,7 @@ Provider Best rate Available from %s + Available up to %s Unavailable for this pair Permission Required Terms of Use @@ -447,8 +449,9 @@ Amount Subtract from send amount The recipient will receive %s + Support The transaction is not completed - Reason: %1$s\nCode:%2$s + Reason: %1$s\nCode: %2$s Confirm %1$s at %2$s Address @@ -482,8 +485,10 @@ Total exceeds balance The commission fee exceeds the network balance. To continue, it is necessary to replenish the network balance. Fee exceeds balance - The fee for transferring the entire balance is higher. To reduce the commission, you can leave 0.01. Fee is increased + The fee for transferring the entire balance is higher. To reduce the commission, you can leave 0.01. + Reduce by %s XTZ + No, send all Custom fee is high The commission amount is %s times the recommended amount. Make sure that the custom settings are correct. The included commission exceeds the transfer amount, leading to a negative value @@ -519,6 +524,7 @@ Transaction has been successfully signed and sent to the blockchain node. Wallet balance will be updated in a while Invalid address Transaction sent + %s (%s) Buy now I have a promo code… Tangem Wallet @@ -545,7 +551,6 @@ Approve Error: %s You swap - There was an error. Please try again. Give Permission Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome. Insufficient funds @@ -705,8 +710,9 @@ To make a transaction you need to deposit some %1$s %2$s Unable to cover %s fee Service temporarily unavailable - Please change the amount to swap + Please change the amount to swap The amount to swap must be at least %s + The amount of tokens to be swapped must not exceed %s This card might be a production sample or counterfeit Authenticity check failed Only %s signatures are left on this card. You must withdraw all of your funds. diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt new file mode 100644 index 0000000000..59c9b5143d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountTextField.kt @@ -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 { + 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 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt index 7e0e1643c6..1e29006979 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt @@ -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), + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt index b3f27e218e..278c631728 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt @@ -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) + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt new file mode 100644 index 0000000000..d25b9e7bc4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt @@ -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 InputRowEnter + */ +@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() }, + ) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt new file mode 100644 index 0000000000..273e0ab9ab --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt @@ -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 Input Row Enter + * @see Input Row Enter Info + */ +@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), + ) + } + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index 1fbc40be6a..e3170a4467 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt @@ -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 } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt new file mode 100644 index 0000000000..8cc2cf7bda --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt @@ -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) { + "" + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/InputNumberFormatter.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/InputNumberFormatter.kt index 7ed7c4a7c4..7873012994 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/InputNumberFormatter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/InputNumberFormatter.kt @@ -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, ) { diff --git a/core/ui/src/main/res/drawable/ic_aptos_22.xml b/core/ui/src/main/res/drawable/ic_aptos_22.xml new file mode 100644 index 0000000000..5a01778f17 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_aptos_22.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/img_aptos_22.xml b/core/ui/src/main/res/drawable/img_aptos_22.xml new file mode 100644 index 0000000000..acfa723f51 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_aptos_22.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt index 5db470b916..3beae6ac03 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt @@ -10,12 +10,16 @@ class PeriodicTask( private val task: suspend () -> Result, 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 { diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt index fa7d68bd98..fc4296fdb8 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt @@ -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, diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index e5cf1cec6c..7c515253c2 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -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 diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt b/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt index f4376a8ef5..0cbee25c28 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt @@ -24,6 +24,12 @@ interface CardTypesResolver { fun isBadWallet(): Boolean + fun isJrWallet(): Boolean + + fun isGrimWallet(): Boolean + + fun isSatoshiFriendsWallet(): Boolean + fun isWhiteWallet(): Boolean fun isWallet2(): Boolean diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt b/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt index f05317c4e6..db6be16a4d 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt @@ -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" diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/extensions/Blockchain.kt b/domain/legacy/src/main/java/com/tangem/domain/common/extensions/Blockchain.kt index b6f3d8aba6..9d0357e1f1 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/extensions/Blockchain.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/extensions/Blockchain.kt @@ -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 } } diff --git a/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt b/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt index f715061ac7..826ba5a966 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt @@ -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 } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Amount.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Amount.kt new file mode 100644 index 0000000000..eb777dbcca --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Amount.kt @@ -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, + ) + }, +) \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt index f0acf4eef2..e393e4fbd3 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt @@ -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 diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt new file mode 100644 index 0000000000..b1f054e498 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetBalanceNotEnoughForFeeWarningUseCase.kt @@ -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 = 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, + ) + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt index ffe6e79c3b..ed3cbfad6c 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt @@ -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 { + ): Either { 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 { + val operations = CurrenciesStatusesOperations( + userWalletId = userWalletId, + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + ) + + return operations.getPrimaryCurrencyStatusSync() + .mapLeft { error -> error.mapToCurrencyError() } } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt index 3545e10ba9..b43052898e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt @@ -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, + ) + } } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt new file mode 100644 index 0000000000..d003a95067 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/IsAmountSubtractAvailableUseCase.kt @@ -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 = + 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 + } + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/legacy/TradeCryptoAction.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/legacy/TradeCryptoAction.kt index 85abbb2bc8..1657a3ea05 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/legacy/TradeCryptoAction.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/legacy/TradeCryptoAction.kt @@ -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() - } + ) } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt index a86698763b..92d6969de0 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt @@ -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 = 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>> { return flow { val nonEmptyCurrencies = recover( diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/NetworksRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/NetworksRepository.kt index 8e1b1103ff..6ec3fbb11a 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/NetworksRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/NetworksRepository.kt @@ -41,6 +41,8 @@ interface NetworksRepository { suspend fun getNetworkStatusesSync( userWalletId: UserWalletId, networks: Set, - refresh: Boolean, + refresh: Boolean = false, ): Set + + fun isNeedToCreateAccountWithoutReserve(network: Network): Boolean } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockNetworksRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockNetworksRepository.kt index f345dbedeb..ecf1d2a23b 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockNetworksRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockNetworksRepository.kt @@ -32,4 +32,6 @@ internal class MockNetworksRepository( ): Set { return getNetworkStatusesUpdates(userWalletId, networks).first() } + + override fun isNeedToCreateAccountWithoutReserve(network: Network) = false } \ No newline at end of file diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index 571b6c0f50..f0345df888 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -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) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt index 6c38bc0e7d..70c8b7d0ae 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt @@ -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 } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt index 8ed9705902..2d5536cefb 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt @@ -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 { diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt index e27a7d2625..ae697ceb08 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt @@ -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> { - 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( diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt index 1e710033ae..c8ed1fe905 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt @@ -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, + ) } } } diff --git a/features/send/api/build.gradle.kts b/features/send/api/build.gradle.kts index 6b742b5542..eb561fcf20 100644 --- a/features/send/api/build.gradle.kts +++ b/features/send/api/build.gradle.kts @@ -10,7 +10,6 @@ android { } dependencies { - implementation(projects.domain.tokens.models) /** AndroidX */ implementation(deps.androidx.fragment.ktx) diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts index 53a6d8a573..46295abadb 100644 --- a/features/send/impl/build.gradle.kts +++ b/features/send/impl/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(deps.lifecycle.compose) implementation(deps.jodatime) implementation(deps.timber) + implementation(deps.reKotlin) /** Compose */ implementation(deps.compose.accompanist.systemUiController) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/DefaultSendRouter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/DefaultSendRouter.kt index 360165f228..db243390ea 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/DefaultSendRouter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/DefaultSendRouter.kt @@ -23,6 +23,7 @@ internal class DefaultSendRouter( } override fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) { + reduxNavController.popBackStack() reduxNavController.navigate( action = NavigationAction.NavigateTo( screen = AppScreen.WalletDetails, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt new file mode 100644 index 0000000000..54eb2f7868 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEvent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEvent.kt new file mode 100644 index 0000000000..2fdb687d96 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEvent.kt @@ -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() +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt new file mode 100644 index 0000000000..5ecb7dc55c --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt @@ -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, + 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.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, + ), + ) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt index 6d2975319a..cdbd005127 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt @@ -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( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotificationFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotificationFactory.kt index ce29bd2c60..18e0b4f34c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotificationFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotificationFactory.kt @@ -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, private val userWalletProvider: Provider, private val walletManagersFacade: WalletManagersFacade, + private val clickIntents: SendClickIntents, ) { fun create(): Flow> = 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.addExceedBalanceNotification( feeAmount: BigDecimal, receivedAmount: BigDecimal, @@ -173,16 +189,30 @@ internal class SendNotificationFactory( } } - private fun MutableList.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.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") } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt index 70fac6ad0d..23b9ce0dfb 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt @@ -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, @@ -40,25 +37,28 @@ internal class SendStateFactory( private val cryptoCurrencyStatusProvider: Provider, private val validateWalletMemoUseCase: ValidateWalletMemoUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, - coinCryptoCurrencyStatusProvider: Provider, ) { 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(), ), ) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt new file mode 100644 index 0000000000..14f9cc265d --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt @@ -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 { + 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 + } + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt index fb2918c11d..4407d17d0f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt @@ -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> = MutableStateFlow(PagingData.empty()), val currentState: MutableStateFlow, + val event: StateEvent, ) @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, - 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 = 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, ) : 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 = persistentListOf(), ) : SendStates() } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt index 97ad5f2dfc..4edb445226 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt @@ -11,22 +11,16 @@ internal class StateRouter( var currentState: MutableStateFlow = 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 } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountCurrencyConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountCurrencyConverter.kt new file mode 100644 index 0000000000..0f95e9fad7 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountCurrencyConverter.kt @@ -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, + private val cryptoCurrencyStatusProvider: Provider, +) : Converter { + 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, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt index c80815c345..5bda8cc991 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt @@ -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( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeCalculation.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeCalculation.kt index 1805ce3f6a..cd0877210f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeCalculation.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeCalculation.kt @@ -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, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeNotificationFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeNotificationFactory.kt index fa63899356..c434a6182f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeNotificationFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeNotificationFactory.kt @@ -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, + private val cryptoCurrencyStatusProvider: Provider, + private val currentStateProvider: Provider, private val userWalletProvider: Provider, private val clickIntents: SendClickIntents, + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, ) { - operator fun invoke(feeState: SendStates.FeeState): ImmutableList { - 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.addFeeUnreachableNotification(feeSelectorState: FeeSelectorState) { + if (feeSelectorState is FeeSelectorState.Error) { + add(SendFeeNotification.Warning.NetworkFeeUnreachable(clickIntents::feeReload)) + } } private fun MutableList.addTooLowNotification( @@ -59,35 +84,62 @@ internal class FeeNotificationFactory( } } - private fun MutableList.addFeeCoverageNotification() { - // TODO add fee coverage condition [REDACTED_JIRA] - add(SendFeeNotification.Warning.NetworkCoverage) + private fun MutableList.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.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.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 } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeSelectorState.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeSelectorState.kt index c5a392c4a9..9751e61eae 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeSelectorState.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeSelectorState.kt @@ -16,6 +16,8 @@ internal sealed class FeeSelectorState { val selectedFee: FeeType = FeeType.MARKET, val customValues: ImmutableList = persistentListOf(), ) : FeeSelectorState() + + object Error : FeeSelectorState() } enum class FeeType { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt new file mode 100644 index 0000000000..dd003ef56f --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt @@ -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, + private val coinCryptoCurrencyStatusProvider: Provider, + private val cryptoCurrencyStatusProvider: Provider, + private val appCurrencyProvider: Provider, +) { + 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): 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, + ): 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, + ) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeCustomFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeCustomFieldConverter.kt index ff9da4a71f..d6d6168f37 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeCustomFieldConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeCustomFieldConverter.kt @@ -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" + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeNotification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeNotification.kt index 8763cf5cc2..fbf72931af 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeNotification.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeNotification.kt @@ -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( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeStateConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeStateConverter.kt index d87f1a2795..312ed32c90 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeStateConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeStateConverter.kt @@ -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, -) : Converter { +internal class SendFeeStateConverter : Converter { 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(), ) } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt index 66f31d9233..7a6c696c1a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt @@ -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, + private val cryptoCurrencyStatusProvider: Provider, ) : Converter { 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 = "[.,]" - } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt index 9ab26bd83d..25a63f78f8 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt @@ -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 { + private val cryptoCurrencyStatusProvider: Provider, + private val appCurrencyProvider: Provider, +) : Converter { - 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), + ) } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendTextField.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendTextField.kt index 456359a16c..42b2ac5b0d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendTextField.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendTextField.kt @@ -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() } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendEventEffect.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendEventEffect.kt new file mode 100644 index 0000000000..84e638eb8a --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendEventEffect.kt @@ -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, snackbarHostState: SnackbarHostState) { + val resources = LocalContext.current.resources + var alertConfig by remember { mutableStateOf(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, + ) +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt index eae5388921..8fe5c5f53d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt @@ -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 diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt index f6471c020d..c2ca7a26d8 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt @@ -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( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountFieldContainer.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountFieldContainer.kt index 4f95280719..afd7b30b6e 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountFieldContainer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountFieldContainer.kt @@ -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, ) } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendCustomFeeEthereum.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendCustomFeeEthereum.kt index 69bc85a649..a5eba38a01 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendCustomFeeEthereum.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendCustomFeeEthereum.kt @@ -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, 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, + ), + ) + } + } } } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedAndFeeContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedAndFeeContent.kt index 4c8c06116d..43ab51b3ac 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedAndFeeContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedAndFeeContent.kt @@ -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 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 + 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(), ) } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelector.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelector.kt index f221817a1d..db6be28780 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelector.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelector.kt @@ -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( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt index e83ba769bd..d16846ae48 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt @@ -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() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt index 57187dffc1..d2741dffcc 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt @@ -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, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFieldWithPaste.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFieldWithPaste.kt index 8b3c83bfb0..758fef6f57 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFieldWithPaste.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFieldWithPaste.kt @@ -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), ) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt index b9f84d7264..62330d3e7c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt @@ -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 TangemTheme.colors.background.action + else -> TangemTheme.colors.button.disabled + }, iconTint = when (it) { is SendNotification.Error -> TangemTheme.colors.icon.warning is SendNotification.Warning -> null diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt index f79bf242b5..cd3c448c3f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt @@ -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 } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt index 133e865e26..32629e642a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt @@ -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? { + 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 } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt index fa28cf44f2..dca890c55e 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt @@ -11,7 +11,7 @@ internal class ErrorsDataConverter( private val jsonAdapter: JsonAdapter, ) : Converter { - @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) diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt index 0da33b0bcf..21c40c5588 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt @@ -18,6 +18,8 @@ sealed class DataError { data class ExchangeTooSmallAmountError(override val code: Int, val amount: SwapAmount) : DataError() + data class ExchangeTooBigAmountError(override val code: Int, val amount: SwapAmount) : DataError() + data class ExchangeNotEnoughAllowanceError(override val code: Int, val currentAllowance: BigDecimal) : DataError() data class ExchangeNotEnoughBalanceError(override val code: Int) : DataError() diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 08acf5d871..cbf8cd6d81 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -9,6 +9,7 @@ import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.WalletsStateHolder @@ -121,13 +122,13 @@ class SwapDomainModule { @Singleton fun provideSendTransactionUseCase( @SwapScope isDemoCardUseCase: IsDemoCardUseCase, - walletManagersFacade: WalletManagersFacade, cardSdkConfigRepository: CardSdkConfigRepository, + transactionRepository: TransactionRepository, ): SendTransactionUseCase { return SendTransactionUseCase( isDemoCardUseCase = isDemoCardUseCase, cardSdkConfigRepository = cardSdkConfigRepository, - walletManagersFacade = walletManagersFacade, + transactionRepository = transactionRepository, ) } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index bbe757b01b..4e77ff7dc8 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -31,6 +31,7 @@ data class SwapStateHolder( val bottomSheetConfig: TangemBottomSheetConfig? = null, val swapButton: SwapButton, + val shouldShowMaxAmount: Boolean, val tosState: TosState? = null, val onRefresh: () -> Unit, diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt index 931563da97..eaddec7e0c 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -146,7 +146,7 @@ private fun ProviderContentState( } Row( modifier = Modifier.padding( - top = TangemTheme.dimens.spacing8, + top = TangemTheme.dimens.spacing6, end = TangemTheme.dimens.spacing56, ), ) { @@ -238,7 +238,7 @@ private fun ProviderUnavailableState( text = it.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + modifier = Modifier.padding(top = TangemTheme.dimens.spacing6), ) } } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index d0d611881d..d6a880f213 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -87,6 +87,7 @@ internal class StateBuilder( changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, onShowPermissionBottomSheet = actions.openPermissionBottomSheet, providerState = ProviderState.Empty(), + shouldShowMaxAmount = false, priceImpact = PriceImpact.Empty(), ) } @@ -187,6 +188,7 @@ internal class StateBuilder( permissionState = uiStateHolder.permissionState, changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, priceImpact = PriceImpact.Empty(), + shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toToken), ) } @@ -276,9 +278,14 @@ internal class StateBuilder( PriceImpact.Empty() }, tosState = createTosState(swapProvider), + shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrencyStatus.currency), ) } + private fun shouldShowMaxAmount(fromToken: CryptoCurrency, toCurrency: CryptoCurrency): Boolean { + return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency.network.id) + } + private fun createTosState(swapProvider: SwapProvider): TosState { return TosState( tosLink = swapProvider.termsOfUse?.let { @@ -527,6 +534,16 @@ internal class StateBuilder( onProviderClick = onProviderClick, ) } + is DataError.ExchangeTooBigAmountError -> { + swapProvider.convertToAvailableFromProviderState( + alertText = resourceReference( + R.string.express_provider_max_amount, + wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)), + ), + selectionType = selectionType, + onProviderClick = onProviderClick, + ) + } else -> { ProviderState.Empty() } @@ -541,7 +558,17 @@ internal class StateBuilder( id = R.string.warning_express_too_minimal_amount_title, formatArgs = wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)), ), - subtitle = resourceReference(R.string.warning_express_too_minimal_amount_description), + subtitle = resourceReference(R.string.warning_express_wrong_amount_description), + iconResId = R.drawable.ic_alert_circle_24, + ), + ) + is DataError.ExchangeTooBigAmountError -> SwapWarning.GeneralError( + notificationConfig = NotificationConfig( + title = resourceReference( + id = R.string.warning_express_too_maximum_amount_title, + formatArgs = wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)), + ), + subtitle = resourceReference(R.string.warning_express_wrong_amount_description), iconResId = R.drawable.ic_alert_circle_24, ), ) @@ -553,7 +580,7 @@ internal class StateBuilder( resourceReference(R.string.warning_express_refresh_required_title) }, subtitle = if (dataError is DataError.UnknownError) { - resourceReference(R.string.swapping_generic_error) + resourceReference(R.string.common_unknown_error) } else { resourceReference(R.string.generic_error_code, wrappedList(dataError.code.toString())) }, diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index bb85650ace..8542161c27 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -86,7 +86,7 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi } } - if (keyboard is Keyboard.Opened) { + if (state.shouldShowMaxAmount && keyboard is Keyboard.Opened) { Text( text = stringResource(id = R.string.send_max_amount_label), style = TangemTheme.typography.button, @@ -109,7 +109,7 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi val message = if (state.alert.type == GenericWarningType.NETWORK) { stringResource(id = R.string.disclaimer_error_loading) } else { - state.alert.message?.resolveReference() ?: stringResource(id = R.string.swapping_generic_error) + state.alert.message?.resolveReference() ?: stringResource(id = R.string.common_unknown_error) } SimpleOkDialog( message = message, @@ -338,7 +338,7 @@ private fun SwapWarnings(warnings: List) { } else { it.resolveReference() } - } ?: stringResource(id = R.string.swapping_generic_error) + } ?: stringResource(id = R.string.common_unknown_error) RefreshableWaringCard( title = stringResource(id = R.string.common_warning), description = message, @@ -494,6 +494,7 @@ private val state = SwapStateHolder( blockchainId = "POLYGON", providerState = ProviderState.Loading(), priceImpact = PriceImpact.Empty(), + shouldShowMaxAmount = true, tosState = TosState( tosLink = LegalState( title = stringReference("Terms of Use"), diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index c95f3ed693..ace4cb9006 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -99,8 +99,9 @@ internal class SwapViewModel @Inject constructor( private val lastAmount = mutableStateOf(INITIAL_AMOUNT) private var swapRouter: SwapRouter by Delegates.notNull() - private val isExchangeTooSmallAmountError: (SwapState) -> Boolean = { - it is SwapState.SwapError && it.error is DataError.ExchangeTooSmallAmountError + private val isUserResolvableError: (SwapState) -> Boolean = { + it is SwapState.SwapError && + (it.error is DataError.ExchangeTooSmallAmountError || it.error is DataError.ExchangeTooBigAmountError) } val currentScreen: SwapNavScreen @@ -1001,7 +1002,7 @@ internal class SwapViewModel @Inject constructor( private fun Map.consideredProvidersStates(): Map { return this.filter { - it.value is SwapState.QuotesLoadedState || isExchangeTooSmallAmountError(it.value) + it.value is SwapState.QuotesLoadedState || isUserResolvableError(it.value) } } diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 860b64eec4..81f6868424 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -51,6 +51,8 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.core.datasource) + implementation(projects.core.deepLinks) + implementation(projects.core.deepLinks.global) /** Domain modules */ implementation(projects.domain.appCurrency) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt index 9c3c74dfd6..3a470f1a30 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt @@ -39,6 +39,7 @@ internal class TokenDetailsNotificationsAnalyticsSender( is TokenDetailsNotification.ExistentialDeposit, is TokenDetailsNotification.HasPendingTransactions, is TokenDetailsNotification.NetworksNoAccount, + is TokenDetailsNotification.TopUpWithoutReserve, is TokenDetailsNotification.RentInfo, is TokenDetailsNotification.SwapPromo, -> null diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt index e880e6aa3e..8a4732ea16 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt @@ -147,6 +147,11 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) { ), ) + object TopUpWithoutReserve : Informational( + title = resourceReference(id = R.string.warning_no_account_title), + subtitle = resourceReference(id = R.string.no_account_bnb), + ) + class HasPendingTransactions(val coinSymbol: String) : Informational( title = resourceReference(R.string.warning_send_blocked_pending_transactions_title), subtitle = resourceReference( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt index caad6173cc..c2a5ea5591 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification +import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification.* import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.removeBy @@ -19,13 +20,13 @@ internal class TokenDetailsNotificationConverter( fun removeRentInfo(currentState: TokenDetailsState): ImmutableList { val newNotifications = currentState.notifications.toMutableList() - newNotifications.removeBy { it is TokenDetailsNotification.RentInfo } + newNotifications.removeBy { it is RentInfo } return newNotifications.toImmutableList() } private fun mapToNotification(warning: CryptoCurrencyWarning): TokenDetailsNotification { return when (warning) { - is CryptoCurrencyWarning.BalanceNotEnoughForFee -> TokenDetailsNotification.NetworkFeeWithBuyButton( + is CryptoCurrencyWarning.BalanceNotEnoughForFee -> NetworkFeeWithBuyButton( currency = warning.tokenCurrency, networkName = warning.coinCurrency.name, feeCurrencyName = warning.coinCurrency.name, @@ -35,7 +36,7 @@ internal class TokenDetailsNotificationConverter( is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> { val feeCurrency = warning.feeCurrency if (feeCurrency != null) { - TokenDetailsNotification.NetworkFeeWithBuyButton( + NetworkFeeWithBuyButton( currency = warning.currency, networkName = feeCurrency.network.name, feeCurrencyName = warning.feeCurrencyName, @@ -43,7 +44,7 @@ internal class TokenDetailsNotificationConverter( onBuyClick = { clickIntents.onBuyCoinClick(feeCurrency) }, ) } else { - TokenDetailsNotification.NetworkFee( + NetworkFee( currency = warning.currency, networkName = warning.networkName, feeCurrencyName = warning.feeCurrencyName, @@ -51,23 +52,22 @@ internal class TokenDetailsNotificationConverter( ) } } - is CryptoCurrencyWarning.ExistentialDeposit -> TokenDetailsNotification.ExistentialDeposit( - existentialInfo = warning, - ) - is CryptoCurrencyWarning.Rent -> TokenDetailsNotification.RentInfo( + is CryptoCurrencyWarning.ExistentialDeposit -> ExistentialDeposit(existentialInfo = warning) + is CryptoCurrencyWarning.Rent -> RentInfo( rentInfo = warning, onCloseClick = clickIntents::onCloseRentInfoNotification, ) - CryptoCurrencyWarning.SomeNetworksUnreachable -> TokenDetailsNotification.NetworksUnreachable - is CryptoCurrencyWarning.SomeNetworksNoAccount -> TokenDetailsNotification.NetworksNoAccount( + CryptoCurrencyWarning.SomeNetworksUnreachable -> NetworksUnreachable + is CryptoCurrencyWarning.SomeNetworksNoAccount -> NetworksNoAccount( network = warning.amountCurrency.name, amount = warning.amountToCreateAccount.toString(), symbol = warning.amountCurrency.symbol, ) - is CryptoCurrencyWarning.HasPendingTransactions -> TokenDetailsNotification.HasPendingTransactions( + is CryptoCurrencyWarning.TopUpWithoutReserve -> TopUpWithoutReserve + is CryptoCurrencyWarning.HasPendingTransactions -> HasPendingTransactions( coinSymbol = warning.blockchainSymbol, ) - is CryptoCurrencyWarning.SwapPromo -> TokenDetailsNotification.SwapPromo( + is CryptoCurrencyWarning.SwapPromo -> SwapPromo( onSwapClick = clickIntents::onSwapPromoClick, onCloseClick = clickIntents::onSwapPromoDismiss, ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt index 7a7d53ba09..1fba010ecc 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt @@ -9,6 +9,9 @@ import arrow.core.getOrElse import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.address.AddressType import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.deeplink.DeepLinksRegistry +import com.tangem.core.deeplink.global.BuyCurrencyDeepLink +import com.tangem.core.deeplink.global.SellCurrencyDeepLink import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.resourceReference @@ -22,6 +25,7 @@ import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.settings.ShouldShowSwapPromoTokenUseCase import com.tangem.domain.tokens.* import com.tangem.domain.tokens.legacy.TradeCryptoAction +import com.tangem.domain.tokens.legacy.TradeCryptoAction.TransactionInfo import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.NetworkAddress @@ -87,6 +91,7 @@ internal class TokenDetailsViewModel @Inject constructor( private val isDemoCardUseCase: IsDemoCardUseCase, private val reduxStateHolder: ReduxStateHolder, private val analyticsEventsHandler: AnalyticsEventHandler, + deepLinksRegistry: DeepLinksRegistry, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver, TokenDetailsClickIntents { @@ -148,6 +153,34 @@ internal class TokenDetailsViewModel @Inject constructor( var uiState: TokenDetailsState by mutableStateOf(stateFactory.getInitialState(cryptoCurrency)) private set + init { + deepLinksRegistry.registerWithViewModel( + viewModel = this, + deepLinks = listOf( + BuyCurrencyDeepLink(::onBuyCurrencyDeepLink), + SellCurrencyDeepLink(::onSellCurrencyDeepLink), + ), + ) + } + + private fun onBuyCurrencyDeepLink() { + val currency = cryptoCurrencyStatus?.currency ?: return + analyticsEventsHandler.send(TokenScreenAnalyticsEvent.Bought(currency.symbol)) + } + + private fun onSellCurrencyDeepLink(data: SellCurrencyDeepLink.Data) { + sendCurrency( + status = cryptoCurrencyStatus ?: return, + transactionInfo = data.let { + TransactionInfo( + amount = it.baseCurrencyAmount, + transactionId = it.transactionId, + destinationAddress = it.depositWalletAddress, + ) + }, + ) + } + override fun onCreate(owner: LifecycleOwner) { analyticsEventsHandler.send( event = TokenScreenAnalyticsEvent.DetailsScreenOpened(token = cryptoCurrency.symbol), @@ -330,7 +363,7 @@ internal class TokenDetailsViewModel @Inject constructor( viewModelScope.launch(dispatchers.main) { reduxStateHolder.dispatch( - TradeCryptoAction.New.Buy( + TradeCryptoAction.Buy( userWallet = getUserWalletUseCase(userWalletId).getOrElse { return@launch }, cryptoCurrencyStatus = status, appCurrencyCode = selectedAppCurrencyFlow.value.code, @@ -354,27 +387,31 @@ internal class TokenDetailsViewModel @Inject constructor( override fun onSendClick() { analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonSend(cryptoCurrency.symbol)) - val cryptoCurrencyStatus = cryptoCurrencyStatus ?: return + sendCurrency(status = cryptoCurrencyStatus ?: return) + } + private fun sendCurrency(status: CryptoCurrencyStatus, transactionInfo: TransactionInfo? = null) { viewModelScope.launch(dispatchers.main) { val maybeFeeCurrencyStatus = - getFeePaidCryptoCurrencyStatusSyncUseCase(userWalletId, cryptoCurrencyStatus).getOrNull() + getFeePaidCryptoCurrencyStatusSyncUseCase(userWalletId, status).getOrNull() - when (val currency = cryptoCurrencyStatus.currency) { + when (val currency = status.currency) { is CryptoCurrency.Coin -> { reduxStateHolder.dispatch( - action = TradeCryptoAction.New.SendCoin( + action = TradeCryptoAction.SendCoin( userWallet = getUserWalletUseCase(userWalletId).getOrElse { return@launch }, - coinStatus = cryptoCurrencyStatus, + coinStatus = status, feeCurrencyStatus = maybeFeeCurrencyStatus, + transactionInfo = transactionInfo, ), ) } is CryptoCurrency.Token -> { sendToken( tokenCurrency = currency, - tokenFiatRate = cryptoCurrencyStatus.value.fiatRate, + tokenFiatRate = status.value.fiatRate, feeCurrencyStatus = maybeFeeCurrencyStatus, + transactionInfo = transactionInfo, ) } } @@ -385,6 +422,7 @@ internal class TokenDetailsViewModel @Inject constructor( tokenCurrency: CryptoCurrency.Token, tokenFiatRate: BigDecimal?, feeCurrencyStatus: CryptoCurrencyStatus?, + transactionInfo: TransactionInfo?, ) { viewModelScope.launch(dispatchers.io) { val wallet = getUserWalletUseCase(userWalletId).getOrElse { return@launch } @@ -399,7 +437,7 @@ internal class TokenDetailsViewModel @Inject constructor( .firstOrNull() reduxStateHolder.dispatchWithMain( - action = TradeCryptoAction.New.SendToken( + action = TradeCryptoAction.SendToken( userWallet = wallet, tokenCurrency = tokenCurrency, tokenFiatRate = tokenFiatRate, @@ -408,6 +446,7 @@ internal class TokenDetailsViewModel @Inject constructor( ifRight = { it.value.fiatRate }, ), feeCurrencyStatus = feeCurrencyStatus, + transactionInfo = transactionInfo, ), ) } @@ -440,7 +479,7 @@ internal class TokenDetailsViewModel @Inject constructor( val status = cryptoCurrencyStatus ?: return@showErrorIfDemoModeOrElse reduxStateHolder.dispatch( - TradeCryptoAction.New.Sell( + TradeCryptoAction.Sell( cryptoCurrencyStatus = status, appCurrencyCode = selectedAppCurrencyFlow.value.code, ), @@ -451,7 +490,7 @@ internal class TokenDetailsViewModel @Inject constructor( override fun onSwapClick() { analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonExchange(cryptoCurrency.symbol)) - reduxStateHolder.dispatch(TradeCryptoAction.New.Swap(cryptoCurrency)) + reduxStateHolder.dispatch(TradeCryptoAction.Swap(cryptoCurrency)) } override fun onDismissDialog() { diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index a3207eb5cd..decfa1c37e 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -49,6 +49,8 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.common) + implementation(projects.core.deepLinks) + implementation(projects.core.deepLinks.global) /** Domain modules */ implementation(projects.domain.card) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/deeplink/WalletDeepLinksHandler.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/deeplink/WalletDeepLinksHandler.kt new file mode 100644 index 0000000000..0248645642 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/deeplink/WalletDeepLinksHandler.kt @@ -0,0 +1,156 @@ +package com.tangem.feature.wallet.presentation.deeplink + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.deeplink.DeepLink +import com.tangem.core.deeplink.DeepLinksRegistry +import com.tangem.core.deeplink.global.BuyCurrencyDeepLink +import com.tangem.core.deeplink.global.SellCurrencyDeepLink +import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyUseCase +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.GetNetworkCoinStatusUseCase +import com.tangem.domain.tokens.legacy.TradeCryptoAction +import com.tangem.domain.tokens.legacy.TradeCryptoAction.TransactionInfo +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.models.analytics.TokenScreenAnalyticsEvent +import com.tangem.domain.wallets.models.UserWallet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +internal class WalletDeepLinksHandler @Inject constructor( + private val deepLinksRegistry: DeepLinksRegistry, + private val analyticsEventHandler: AnalyticsEventHandler, + private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, + private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase, + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, + private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase, + private val reduxStateHolder: ReduxStateHolder, +) { + + private var deepLinks: List = emptyList() + + fun registerForSingleCurrencyWallets(viewModel: ViewModel, userWallet: UserWallet) { + if (userWallet.isMultiCurrency) { + deepLinksRegistry.unregister(deepLinks) + } else { + if (deepLinks.isEmpty()) { + deepLinks = getDeepLinks(userWallet, viewModel.viewModelScope) + } + + deepLinksRegistry.register(deepLinks) + } + + viewModel.addCloseable { + deepLinksRegistry.unregister(deepLinks) + } + } + + private fun getDeepLinks(userWallet: UserWallet, scope: CoroutineScope): List { + val sellCurrencyDeepLink = SellCurrencyDeepLink( + onReceive = { data -> + scope.launch { + onSellCurrencyDeepLink(userWallet, data) + } + }, + ) + val buyCurrencyDeepLink = BuyCurrencyDeepLink( + onReceive = { + scope.launch { + onBuyCurrencyDeepLink(userWallet) + } + }, + ) + + return listOf(sellCurrencyDeepLink, buyCurrencyDeepLink) + } + + private suspend fun onSellCurrencyDeepLink(userWallet: UserWallet, data: SellCurrencyDeepLink.Data) { + val cryptoCurrencyStatus = getCryptoCurrencyStatusSyncUseCase(userWallet.walletId) + .getOrNull() ?: return + val feeCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWallet.walletId, + cryptoCurrencyStatus, + ).getOrNull() + + val transactionInfo = data.let { + TransactionInfo( + amount = it.baseCurrencyAmount, + destinationAddress = it.depositWalletAddress, + transactionId = it.transactionId, + ) + } + + when (cryptoCurrencyStatus.currency) { + is CryptoCurrency.Coin -> sendCoin( + userWallet = userWallet, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCurrencyStatus = feeCurrencyStatus, + transactionInfo = transactionInfo, + ) + is CryptoCurrency.Token -> sendToken( + userWallet = userWallet, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCurrencyStatus = feeCurrencyStatus, + transactionInfo = transactionInfo, + ) + } + } + + private suspend fun onBuyCurrencyDeepLink(userWallet: UserWallet) { + val cryptoCurrency = getCryptoCurrencyUseCase(userWallet.walletId).getOrNull() ?: return + + analyticsEventHandler.send(TokenScreenAnalyticsEvent.Bought(cryptoCurrency.symbol)) + } + + private fun sendCoin( + userWallet: UserWallet, + cryptoCurrencyStatus: CryptoCurrencyStatus, + feeCurrencyStatus: CryptoCurrencyStatus?, + transactionInfo: TransactionInfo, + ) { + reduxStateHolder.dispatch( + action = TradeCryptoAction.SendCoin( + userWallet = userWallet, + coinStatus = cryptoCurrencyStatus, + feeCurrencyStatus = feeCurrencyStatus, + transactionInfo = transactionInfo, + ), + ) + } + + private suspend fun sendToken( + userWallet: UserWallet, + cryptoCurrencyStatus: CryptoCurrencyStatus, + feeCurrencyStatus: CryptoCurrencyStatus?, + transactionInfo: TransactionInfo, + ) { + val cryptoCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return + val coinStatus = getNetworkCoinStatusUseCase( + userWalletId = userWallet.walletId, + networkId = cryptoCurrency.network.id, + derivationPath = cryptoCurrency.network.derivationPath, + isSingleWalletWithTokens = false, + ) + .firstOrNull() + ?.getOrNull() + ?: return + + reduxStateHolder.dispatch( + action = TradeCryptoAction.SendToken( + userWallet = userWallet, + tokenCurrency = cryptoCurrency, + tokenFiatRate = cryptoCurrencyStatus.value.fiatRate, + coinFiatRate = coinStatus.value.fiatRate, + feeCurrencyStatus = feeCurrencyStatus, + transactionInfo = transactionInfo, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletImageResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletImageResolver.kt index 963970f85a..7c7adbde20 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletImageResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletImageResolver.kt @@ -31,6 +31,9 @@ internal object WalletImageResolver { cardTypesResolver.isTronWallet() -> userWallet.resolveTronWallet() cardTypesResolver.isKaspaWallet() -> userWallet.resolveKaspaWallet() cardTypesResolver.isBadWallet() -> userWallet.resolveBadWallet() + cardTypesResolver.isJrWallet() -> userWallet.resolveJrWallet() + cardTypesResolver.isGrimWallet() -> userWallet.resolveGrimWallet() + cardTypesResolver.isSatoshiFriendsWallet() -> userWallet.resolveSatoshiWallet() cardTypesResolver.isWallet2() -> userWallet.resolveWallet2() cardTypesResolver.isShibaWallet() -> userWallet.resolveShibaWallet() cardTypesResolver.isTangemWallet() -> userWallet.resolveWallet1() @@ -78,6 +81,27 @@ internal object WalletImageResolver { ) } + private fun UserWallet.resolveJrWallet(): Int? { + return resolveWallet2( + oneBackupResId = R.drawable.ill_jr_card2_120_106, + twoBackupResId = R.drawable.ill_jr_card3_120_106, + ) + } + + private fun UserWallet.resolveGrimWallet(): Int? { + return resolveWallet2( + oneBackupResId = R.drawable.ill_grim_card2_120_106, + twoBackupResId = R.drawable.ill_grim_card3_120_106, + ) + } + + private fun UserWallet.resolveSatoshiWallet(): Int? { + return resolveWallet2( + oneBackupResId = R.drawable.ill_satoshi_card2_120_106, + twoBackupResId = R.drawable.ill_satoshi_card3_120_106, + ) + } + private fun UserWallet.resolveShibaWallet(): Int? { return resolveWallet2( oneBackupResId = R.drawable.ill_shiba_card2_120_106, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt index 065a47b196..8adfe05590 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt @@ -609,7 +609,7 @@ internal class WalletViewModel @Inject constructor( showErrorIfDemoModeOrElse { reduxStateHolder.dispatch( - TradeCryptoAction.New.Buy( + TradeCryptoAction.Buy( userWallet = getWallet(index = state.walletsListConfig.selectedWalletIndex), cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrencyCode = selectedAppCurrencyFlow.value.code, @@ -623,7 +623,7 @@ internal class WalletViewModel @Inject constructor( event = TokenScreenAnalyticsEvent.ButtonExchange(cryptoCurrencyStatus.currency.symbol), ) - reduxStateHolder.dispatch(TradeCryptoAction.New.Swap(cryptoCurrencyStatus.currency)) + reduxStateHolder.dispatch(TradeCryptoAction.Swap(cryptoCurrencyStatus.currency)) } override fun onSingleCurrencySendClick(cryptoCurrencyStatus: CryptoCurrencyStatus?) { @@ -641,7 +641,7 @@ internal class WalletViewModel @Inject constructor( val maybeFeeCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase(userWallet.walletId, coinStatus).getOrNull() reduxStateHolder.dispatch( - action = TradeCryptoAction.New.SendCoin( + action = TradeCryptoAction.SendCoin( userWallet = userWallet, coinStatus = coinStatus, feeCurrencyStatus = maybeFeeCurrencyStatus, @@ -665,7 +665,7 @@ internal class WalletViewModel @Inject constructor( is CryptoCurrency.Coin -> { uiState = stateFactory.getStateWithClosedBottomSheet() reduxStateHolder.dispatch( - action = TradeCryptoAction.New.SendCoin( + action = TradeCryptoAction.SendCoin( userWallet = userWallet, coinStatus = cryptoCurrencyStatus, feeCurrencyStatus = maybeFeeCurrencyStatus, @@ -694,7 +694,7 @@ internal class WalletViewModel @Inject constructor( it.onRight { coinStatus -> uiState = stateFactory.getStateWithClosedBottomSheet() reduxStateHolder.dispatchWithMain( - action = TradeCryptoAction.New.SendToken( + action = TradeCryptoAction.SendToken( userWallet = userWallet, tokenCurrency = requireNotNull(cryptoCurrencyStatus.currency as? CryptoCurrency.Token), tokenFiatRate = cryptoCurrencyStatus.value.fiatRate, @@ -772,7 +772,7 @@ internal class WalletViewModel @Inject constructor( showErrorIfDemoModeOrElse { reduxStateHolder.dispatch( - action = TradeCryptoAction.New.Sell( + action = TradeCryptoAction.Sell( cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrencyCode = selectedAppCurrencyFlow.value.code, ), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModelV2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModelV2.kt index ae097c7991..418da4487e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModelV2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModelV2.kt @@ -13,6 +13,7 @@ import com.tangem.domain.walletconnect.WalletConnectActions import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase +import com.tangem.feature.wallet.presentation.deeplink.WalletDeepLinksHandler import com.tangem.feature.wallet.presentation.router.InnerWalletRouter import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.feature.wallet.presentation.wallet.analytics.utils.SelectedWalletAnalyticsSender @@ -57,6 +58,7 @@ internal class WalletViewModelV2 @Inject constructor( private val reduxStateHolder: ReduxStateHolder, private val screenLifecycleProvider: ScreenLifecycleProvider, private val selectedWalletAnalyticsSender: SelectedWalletAnalyticsSender, + private val walletDeepLinksHandler: WalletDeepLinksHandler, ) : ViewModel() { val uiState: StateFlow = stateHolder.uiState @@ -157,6 +159,11 @@ internal class WalletViewModelV2 @Inject constructor( selectedWalletAnalyticsSender.send(selectedWallet) } + + walletDeepLinksHandler.registerForSingleCurrencyWallets( + viewModel = this, + userWallet = selectedWallet, + ) } .flowOn(dispatchers.main) .launchIn(viewModelScope) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt index b4e85a8422..25c2b29527 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt @@ -111,7 +111,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( feeCurrencyStatus: CryptoCurrencyStatus?, ) { reduxStateHolder.dispatch( - action = TradeCryptoAction.New.SendCoin( + action = TradeCryptoAction.SendCoin( userWallet = userWallet, coinStatus = cryptoCurrencyStatus, feeCurrencyStatus = feeCurrencyStatus, @@ -136,7 +136,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( .collectLatest { it.onRight { coinStatus -> reduxStateHolder.dispatch( - action = TradeCryptoAction.New.SendToken( + action = TradeCryptoAction.SendToken( userWallet = userWallet, tokenCurrency = cryptoCurrency, tokenFiatRate = cryptoCurrencyStatus.fiatRate, @@ -282,7 +282,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( showErrorIfDemoModeOrElse { viewModelScope.launch(dispatchers.main) { reduxStateHolder.dispatch( - action = TradeCryptoAction.New.Sell( + action = TradeCryptoAction.Sell( cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrencyCode = getSelectedAppCurrencyUseCase.unwrap().code, ), @@ -301,7 +301,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( showErrorIfDemoModeOrElse { viewModelScope.launch(dispatchers.main) { reduxStateHolder.dispatch( - TradeCryptoAction.New.Buy( + TradeCryptoAction.Buy( userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus, appCurrencyCode = getSelectedAppCurrencyUseCase.unwrap().code, @@ -316,7 +316,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( event = TokenScreenAnalyticsEvent.ButtonExchange(cryptoCurrencyStatus.currency.symbol), ) - reduxStateHolder.dispatch(TradeCryptoAction.New.Swap(cryptoCurrencyStatus.currency)) + reduxStateHolder.dispatch(TradeCryptoAction.Swap(cryptoCurrencyStatus.currency)) } override fun onExploreClick() { diff --git a/features/wallet/impl/src/main/res/drawable/ill_grim_card2_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_grim_card2_120_106.webp new file mode 100644 index 0000000000..5568ac6e06 Binary files /dev/null and b/features/wallet/impl/src/main/res/drawable/ill_grim_card2_120_106.webp differ diff --git a/features/wallet/impl/src/main/res/drawable/ill_grim_card3_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_grim_card3_120_106.webp new file mode 100644 index 0000000000..f58a7467c0 Binary files /dev/null and b/features/wallet/impl/src/main/res/drawable/ill_grim_card3_120_106.webp differ diff --git a/features/wallet/impl/src/main/res/drawable/ill_jr_card2_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_jr_card2_120_106.webp new file mode 100644 index 0000000000..d4efc8c070 Binary files /dev/null and b/features/wallet/impl/src/main/res/drawable/ill_jr_card2_120_106.webp differ diff --git a/features/wallet/impl/src/main/res/drawable/ill_jr_card3_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_jr_card3_120_106.webp new file mode 100644 index 0000000000..76efd29547 Binary files /dev/null and b/features/wallet/impl/src/main/res/drawable/ill_jr_card3_120_106.webp differ diff --git a/features/wallet/impl/src/main/res/drawable/ill_satoshi_card2_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_satoshi_card2_120_106.webp new file mode 100644 index 0000000000..31310554b3 Binary files /dev/null and b/features/wallet/impl/src/main/res/drawable/ill_satoshi_card2_120_106.webp differ diff --git a/features/wallet/impl/src/main/res/drawable/ill_satoshi_card3_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_satoshi_card3_120_106.webp new file mode 100644 index 0000000000..3652d41e72 Binary files /dev/null and b/features/wallet/impl/src/main/res/drawable/ill_satoshi_card3_120_106.webp differ diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 11552dabef..4f8cc49e6f 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -85,9 +85,9 @@ spr-client = "3.6.2" # endregion Other libraries # region Tangem -tangemBlockchainSdk = "release-app_5.5-469" +tangemBlockchainSdk = "develop-470" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "release-app_5.5-323" +tangemCardSdk = "develop-324" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ # endregion Tangem diff --git a/settings.gradle.kts b/settings.gradle.kts index edf4844e3e..3ac4a3dcf4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -70,6 +70,8 @@ include(":core:navigation") include(":core:res") include(":core:ui") include(":core:utils") +include(":core:deep-links") +include(":core:deep-links:global") // endregion Core modules // region Libs modules @@ -148,4 +150,4 @@ include(":data:txhistory") include(":data:wallets") include(":data:analytics") include(":data:transaction") -// endregion Data modules +// endregion Data modules \ No newline at end of file