Updated on 2026-08-14

This commit is contained in:
Tangem 2024-04-17 13:17:25 +01:00
commit c83eb5353c
133 changed files with 1851 additions and 807 deletions

View file

@ -8,6 +8,7 @@ import com.tangem.core.navigation.NavigationAction
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.dispatchWithMain
import kotlinx.coroutines.*
import timber.log.Timber
import kotlin.time.Duration
@ -125,7 +126,7 @@ internal class LockUserWalletsTimer(
if (wasApplicationStopped) {
settingsRepository.setShouldOpenWelcomeScreenOnResume(value = true)
} else {
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Welcome))
store.dispatchWithMain(NavigationAction.PopBackTo(AppScreen.Welcome))
}
}
}

View file

@ -19,12 +19,14 @@ import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import arrow.core.getOrElse
import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
@ -35,10 +37,13 @@ import com.tangem.data.card.sdk.CardSdkLifecycleObserver
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.tokens.GetPolkadotCheckHasImmortalUseCase
import com.tangem.domain.tokens.GetPolkadotCheckHasResetUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.features.managetokens.navigation.ManageTokensUi
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.tester.api.TesterRouter
@ -96,6 +101,7 @@ val userWalletsListManagerSafe: UserWalletsListManager? get() = store.state.glob
@Deprecated(message = "Provide UserWalletsListManager using DI")
val userWalletsListManager: UserWalletsListManager get() = userWalletsListManagerSafe!!
@Suppress("LargeClass")
@AndroidEntryPoint
class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbackHolder {
@ -142,6 +148,15 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var getPolkadotCheckHasResetUseCase: GetPolkadotCheckHasResetUseCase
@Inject
lateinit var getPolkadotCheckHasImmortalUseCase: GetPolkadotCheckHasImmortalUseCase
@Inject
lateinit var analyticsEventsHandler: AnalyticsEventHandler
internal val viewModel: MainViewModel by viewModels()
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode?>
@ -172,6 +187,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
checkForNotificationPermission()
observeStateUpdates()
observePolkadotAccountHealthCheck()
if (intent != null) {
deepLinksRegistry.launch(intent)
@ -456,4 +472,25 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 0)
}
}
private fun observePolkadotAccountHealthCheck() {
lifecycleScope.launch {
getPolkadotCheckHasResetUseCase()
.flowWithLifecycle(lifecycle, minActiveState = Lifecycle.State.CREATED)
.distinctUntilChanged()
.collect {
analyticsEventsHandler.send(WalletScreenAnalyticsEvent.Token.PolkadotAccountReset(it.second))
}
}
lifecycleScope.launch {
getPolkadotCheckHasImmortalUseCase()
.flowWithLifecycle(lifecycle, minActiveState = Lifecycle.State.CREATED)
.distinctUntilChanged()
.collect {
analyticsEventsHandler.send(
WalletScreenAnalyticsEvent.Token.PolkadotImmortalTransactions(it.second),
)
}
}
}
}

View file

@ -46,7 +46,7 @@ class DialogManager : StoreSubscriber<GlobalState> {
dialog = when (state.dialog) {
is AppDialog.SimpleOkDialogRes -> SimpleOkDialog.create(state.dialog, context)
is StateDialog.ScanFailsDialog -> ScanFailsDialog.create(context)
is StateDialog.ScanFailsDialog -> ScanFailsDialog.create(context, state.dialog.source)
is AppDialog.AddressInfoDialog -> AddressInfoBottomSheetDialog(state.dialog, context)
is AppDialog.TestActionsDialog -> TestActionsBottomSheetDialog(state.dialog, context)
is AppDialog.RussianCardholdersWarningDialog -> RussianCardholdersWarningBottomSheetDialog(

View file

@ -15,7 +15,7 @@ import com.tangem.tap.features.demo.DemoHelper
[REDACTED_AUTHOR]
*/
class CardContextInterceptor(
private val scanResponse: ScanResponse?,
private val scanResponse: ScanResponse,
) : ParamsInterceptor {
override fun id(): String = CardContextInterceptor.id()
@ -28,8 +28,6 @@ class CardContextInterceptor(
}
override fun intercept(params: MutableMap<String, String>) {
scanResponse ?: return
val card = scanResponse.card
params[AnalyticsParam.BATCH] = card.batchId
params[AnalyticsParam.PRODUCT_TYPE] = getProductType(scanResponse)

View file

@ -2,6 +2,7 @@ package com.tangem.tap.common.redux.global
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.CompletionResult
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.navigation.StateDialog
import com.tangem.datasource.config.ConfigManager
import com.tangem.datasource.config.models.ChatConfig
@ -51,7 +52,11 @@ sealed class GlobalAction : Action {
}
object ScanFailsCounter {
data class ChooseBehavior(val result: CompletionResult<ScanResponse>) : GlobalAction()
data class ChooseBehavior(
val result: CompletionResult<ScanResponse>,
val analyticsSource: AnalyticsParam.ScreensSources,
) : GlobalAction()
object Reset : GlobalAction()
object Increment : GlobalAction()
}

View file

@ -4,6 +4,7 @@ import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.guard
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.navigation.StateDialog
import com.tangem.datasource.config.models.Config
@ -54,14 +55,7 @@ private fun handleAction(action: Action, appState: () -> AppState?) {
when (action.result) {
is CompletionResult.Success -> store.dispatch(GlobalAction.ScanFailsCounter.Reset)
is CompletionResult.Failure -> {
if (action.result.error is TangemSdkError.UserCancelled) {
store.dispatch(GlobalAction.ScanFailsCounter.Increment)
if (store.state.globalState.scanCardFailsCounter >= 2) {
store.dispatchDialogShow(StateDialog.ScanFailsDialog)
}
} else {
store.dispatch(GlobalAction.ScanFailsCounter.Reset)
}
handleFailureChooseBehaviour(action.result, action.analyticsSource)
}
}
}
@ -184,6 +178,26 @@ private fun handleAction(action: Action, appState: () -> AppState?) {
}
}
private fun handleFailureChooseBehaviour(
result: CompletionResult.Failure<ScanResponse>,
analyticsSource: AnalyticsParam.ScreensSources,
) {
if (result.error is TangemSdkError.UserCancelled) {
store.dispatch(GlobalAction.ScanFailsCounter.Increment)
if (store.state.globalState.scanCardFailsCounter >= 2) {
val scanFailsSource = when (analyticsSource) {
is AnalyticsParam.ScreensSources.SignIn -> StateDialog.ScanFailsSource.SIGN_IN
is AnalyticsParam.ScreensSources.Settings -> StateDialog.ScanFailsSource.SETTINGS
is AnalyticsParam.ScreensSources.Intro -> StateDialog.ScanFailsSource.INTRO
else -> StateDialog.ScanFailsSource.MAIN
}
store.dispatchDialogShow(StateDialog.ScanFailsDialog(scanFailsSource))
}
} else {
store.dispatch(GlobalAction.ScanFailsCounter.Reset)
}
}
private fun restoreAppCurrency() {
scope.launch {
val currency = store.inject(DaggerGraphState::appCurrencyRepository)

View file

@ -4,7 +4,9 @@ import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.navigation.StateDialog
import com.tangem.tap.common.extensions.dispatchDialogHide
import com.tangem.tap.common.feedback.ScanFailsEmail
import com.tangem.tap.common.redux.global.GlobalAction
@ -15,12 +17,18 @@ import com.tangem.wallet.R
[REDACTED_AUTHOR]
*/
object ScanFailsDialog {
fun create(context: Context): AlertDialog {
fun create(context: Context, source: StateDialog.ScanFailsSource): AlertDialog {
return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply {
setTitle(context.getString(R.string.common_warning))
setMessage(R.string.alert_troubleshooting_scan_card_title)
setPositiveButton(R.string.alert_button_request_support) { _, _ ->
Analytics.send(Basic.ButtonSupport())
val sourceAnalytics = when (source) {
StateDialog.ScanFailsSource.MAIN -> AnalyticsParam.ScreensSources.Main
StateDialog.ScanFailsSource.SIGN_IN -> AnalyticsParam.ScreensSources.SignIn
StateDialog.ScanFailsSource.SETTINGS -> AnalyticsParam.ScreensSources.Settings
StateDialog.ScanFailsSource.INTRO -> AnalyticsParam.ScreensSources.Intro
}
Analytics.send(Basic.ButtonSupport(sourceAnalytics))
store.dispatch(GlobalAction.SendEmail(ScanFailsEmail()))
}
setNeutralButton(R.string.common_cancel) { _, _ -> }

View file

@ -3,6 +3,9 @@ package com.tangem.tap.di
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.tokens.GetPolkadotCheckHasImmortalUseCase
import com.tangem.domain.tokens.GetPolkadotCheckHasResetUseCase
import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository
import com.tangem.tap.domain.sdk.TangemSdkManager
import com.tangem.tap.domain.scanCard.repository.DefaultScanCardRepository
import com.tangem.tap.network.exchangeServices.DefaultRampManager
@ -46,4 +49,20 @@ internal object ActivityModule {
fun provideActivityDelayedWorkCoroutineScope(): CoroutineScope {
return CoroutineScope(SupervisorJob() + Dispatchers.IO)
}
@Provides
@Singleton
fun provideGetPolkadotCheckHasResetUseCase(
polkadotAccountHealthCheckRepository: PolkadotAccountHealthCheckRepository,
): GetPolkadotCheckHasResetUseCase {
return GetPolkadotCheckHasResetUseCase(polkadotAccountHealthCheckRepository)
}
@Provides
@Singleton
fun provideGetPolkadotCheckHasImmortalUseCase(
polkadotAccountHealthCheckRepository: PolkadotAccountHealthCheckRepository,
): GetPolkadotCheckHasImmortalUseCase {
return GetPolkadotCheckHasImmortalUseCase(polkadotAccountHealthCheckRepository)
}
}

View file

@ -16,7 +16,7 @@ import dagger.hilt.android.scopes.ViewModelScoped
@Module
@InstallIn(ViewModelComponent::class)
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
internal object TokensDomainModule {
@Provides
@ -341,4 +341,12 @@ internal object TokensDomainModule {
): IsAmountSubtractAvailableUseCase {
return IsAmountSubtractAvailableUseCase(currenciesRepository, dispatchers)
}
@Provides
@ViewModelScoped
fun provideRunPolkadotAccountHealthCheckUseCase(
repository: PolkadotAccountHealthCheckRepository,
): RunPolkadotAccountHealthCheckUseCase {
return RunPolkadotAccountHealthCheckUseCase(repository)
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.tap.domain.scanCard
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.extensions.inject
@ -27,7 +27,7 @@ internal class DefaultScanCardProcessor : ScanCardProcessor {
@Suppress("LongParameterList")
override suspend fun scan(
analyticsEvent: AnalyticsEvent?,
analyticsSource: AnalyticsParam.ScreensSources,
cardId: String?,
onProgressStateChange: suspend (showProgress: Boolean) -> Unit,
onWalletNotCreated: suspend () -> Unit,
@ -37,7 +37,7 @@ internal class DefaultScanCardProcessor : ScanCardProcessor {
) {
if (isNewCardScanningEnabled) {
UseCaseScanProcessor.scan(
analyticsEvent,
analyticsSource,
cardId,
onProgressStateChange,
onWalletNotCreated,
@ -47,7 +47,7 @@ internal class DefaultScanCardProcessor : ScanCardProcessor {
)
} else {
LegacyScanProcessor.scan(
analyticsEvent,
analyticsSource,
cardId,
onProgressStateChange,
onWalletNotCreated,

View file

@ -7,6 +7,8 @@ import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.domain.common.TapWorkarounds.canSkipBackup
@ -45,7 +47,7 @@ internal object LegacyScanProcessor {
@Suppress("LongParameterList")
suspend fun scan(
analyticsEvent: AnalyticsEvent?,
analyticsSource: AnalyticsParam.ScreensSources,
cardId: String?,
onProgressStateChange: suspend (showProgress: Boolean) -> Unit,
onWalletNotCreated: suspend () -> Unit,
@ -59,7 +61,8 @@ internal object LegacyScanProcessor {
val result = tangemSdkManager.scanProduct(cardId)
store.dispatchOnMain(GlobalAction.ScanFailsCounter.ChooseBehavior(result))
val analyticsEvent = Basic.CardWasScanned(analyticsSource)
store.dispatchOnMain(GlobalAction.ScanFailsCounter.ChooseBehavior(result, analyticsSource))
result
.doOnFailure { error ->

View file

@ -4,7 +4,7 @@ import arrow.fx.coroutines.resourceScope
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
@ -44,7 +44,7 @@ internal object UseCaseScanProcessor {
@Suppress("LongParameterList")
suspend fun scan(
analyticsEvent: AnalyticsEvent?,
analyticsSource: AnalyticsParam.ScreensSources,
cardId: String?,
onProgressStateChange: suspend (showProgress: Boolean) -> Unit,
onWalletNotCreated: suspend () -> Unit,
@ -54,10 +54,12 @@ internal object UseCaseScanProcessor {
) = progressScope(onProgressStateChange) {
val scanCardUseCase = store.inject(DaggerGraphState::scanCardUseCase)
val chains = buildList {
add(FailedScansCounterChain(UseCaseScanProcessor::showMaxUnsuccessfulScansReachedDialog))
if (analyticsEvent != null) {
add(AnalyticsChain(analyticsEvent))
}
add(
FailedScansCounterChain(
{ showMaxUnsuccessfulScansReachedDialog(analyticsSource) },
),
)
add(AnalyticsChain(Basic.CardWasScanned(analyticsSource)))
add(DisclaimerChain(store, disclaimerWillShow))
add(CheckForOnboardingChain(store, store.state.globalState.tapWalletManager))
}
@ -68,8 +70,14 @@ internal object UseCaseScanProcessor {
)
}
private fun showMaxUnsuccessfulScansReachedDialog() {
store.dispatchDialogShow(StateDialog.ScanFailsDialog)
private fun showMaxUnsuccessfulScansReachedDialog(source: AnalyticsParam.ScreensSources) {
val scanFailsSource = when (source) {
is AnalyticsParam.ScreensSources.SignIn -> StateDialog.ScanFailsSource.SIGN_IN
is AnalyticsParam.ScreensSources.Settings -> StateDialog.ScanFailsSource.SETTINGS
is AnalyticsParam.ScreensSources.Intro -> StateDialog.ScanFailsSource.INTRO
else -> StateDialog.ScanFailsSource.MAIN
}
store.dispatchDialogShow(StateDialog.ScanFailsDialog(scanFailsSource))
}
private suspend fun proceedWithException(

View file

@ -81,6 +81,10 @@ internal class BiometricUserWalletsListManager(
state.update { State() }
}
override fun isLockable(): Boolean {
return true
}
override suspend fun select(userWalletId: UserWalletId): CompletionResult<UserWallet> = catching {
if (state.value.selectedUserWalletId == userWalletId) {
return@catching findSelectedUserWallet()!!

View file

@ -118,6 +118,10 @@ internal class GeneralUserWalletsListManager(
}
}
override fun isLockable(): Boolean {
return implementation.value.isLockable()
}
private fun subscribeOnCurrentManager() {
appPreferencesStore.get(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
.distinctUntilChanged()

View file

@ -85,6 +85,10 @@ internal class RuntimeUserWalletsListManager : UserWalletsListManager {
state.value.userWallet ?: walletNotFound()
}
override fun isLockable(): Boolean {
return false
}
private fun saveInternal(userWallet: UserWallet): CompletionResult<Unit> = catching {
state.update { prevState ->
prevState.copy(

View file

@ -6,7 +6,6 @@ import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.core.UserCodeRequestPolicy
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.Basic
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.core.ui.extensions.TextReference
@ -493,7 +492,7 @@ class DetailsMiddleware {
cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = shouldSaveAccessCodes)
store.inject(DaggerGraphState::scanCardProcessor).scan(
analyticsEvent = Basic.CardWasScanned(CoreAnalyticsParam.ScannedFrom.MyWallets),
analyticsSource = CoreAnalyticsParam.ScreensSources.Settings,
onWalletNotCreated = {
// No need to rollback policy, continue with the policy set before the card scan
store.dispatchWithMain(DetailsAction.ScanAndSaveUserWallet.Success)

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import com.tangem.common.extensions.guard
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
@ -146,7 +147,7 @@ internal class DetailsViewModel(
}
private fun sendFeedback() {
Analytics.send(Basic.ButtonSupport())
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Settings))
if (feedbackManagerFeatureToggles.isLocalLogsEnabled) {
mainScope.launch {
val email = getSupportFeedbackEmailUseCase()

View file

@ -1,8 +1,5 @@
package com.tangem.tap.features.home.redux
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import kotlinx.coroutines.CoroutineScope
import org.rekotlin.Action
@ -16,11 +13,9 @@ sealed class HomeAction : Action {
/**
* Action for scanning card
*
* @property analyticsEvent analytics event
* @property scope lifecycle scope. It will be canceled when lifecycle-aware component is destroyed
*/
data class ReadCard(
val analyticsEvent: AnalyticsEvent? = Basic.CardWasScanned(AnalyticsParam.ScannedFrom.Introduction),
val scope: CoroutineScope,
) : HomeAction()

View file

@ -5,7 +5,7 @@ import com.tangem.common.doOnResult
import com.tangem.common.doOnSuccess
import com.tangem.common.extensions.guard
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
@ -59,7 +59,7 @@ private fun handleHomeAction(action: Action) {
}
is HomeAction.ReadCard -> {
action.scope.launch {
readCard(action.analyticsEvent)
readCard()
}
}
is HomeAction.GoToShop -> {
@ -75,7 +75,7 @@ private fun handleHomeAction(action: Action) {
}
}
private suspend fun readCard(analyticsEvent: AnalyticsEvent?) {
private suspend fun readCard() {
val shouldSaveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes()
store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy(
@ -83,7 +83,7 @@ private suspend fun readCard(analyticsEvent: AnalyticsEvent?) {
)
store.inject(DaggerGraphState::scanCardProcessor).scan(
analyticsEvent = analyticsEvent,
analyticsSource = AnalyticsParam.ScreensSources.Intro,
onProgressStateChange = { showProgress ->
if (showProgress) {
store.dispatch(HomeAction.ScanInProgress(scanInProgress = true))

View file

@ -17,6 +17,7 @@ import com.tangem.domain.userwallets.UserWalletIdBuilder
import com.tangem.tap.*
import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter
import com.tangem.tap.common.extensions.*
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.saveWallet.redux.SaveWalletAction
import com.tangem.tap.proxy.redux.DaggerGraphState
import kotlinx.coroutines.delay
@ -42,7 +43,8 @@ object OnboardingHelper {
response.cardTypesResolver.isWallet2() || response.cardTypesResolver.isShibaWallet() -> {
val emptyWallets = response.card.wallets.isEmpty()
val activationInProgress = onboardingManager?.isActivationInProgress(cardId)
val isNoBackup = response.card.backupStatus == CardDTO.BackupStatus.NoBackup
val isNoBackup = response.card.backupStatus == CardDTO.BackupStatus.NoBackup &&
!DemoHelper.isDemoCard(response)
emptyWallets || activationInProgress == true || isNoBackup
}

View file

@ -5,6 +5,7 @@ import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.tap.common.feedback.SupportInfo
import com.tangem.tap.common.redux.global.GlobalAction
@ -21,7 +22,7 @@ class OnboardingMenuProvider : MenuProvider {
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.menu_item_chat_support -> {
Analytics.send(Basic.ButtonSupport())
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Intro))
// changed on email support [REDACTED_TASK_KEY]
store.dispatch(GlobalAction.SendEmail(SupportInfo()))
true

View file

@ -22,6 +22,7 @@ import com.tangem.common.CardIdFormatter
import com.tangem.common.CompletionResult
import com.tangem.common.core.CardIdDisplayFormat
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.ui.extensions.setStatusBarColor
import com.tangem.domain.common.util.cardTypesResolver
@ -502,7 +503,7 @@ class OnboardingWalletFragment :
private fun makeSeedPhraseRouter(): SeedPhraseRouter = SeedPhraseRouter(
onBack = ::legacyOnBackHandler,
onOpenChat = {
Analytics.send(Basic.ButtonSupport())
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Intro))
// changed on email support [REDACTED_TASK_KEY]
store.dispatch(GlobalAction.SendEmail(SupportInfo()))
},

View file

@ -4,6 +4,7 @@ import android.app.Dialog
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.tap.common.extensions.dispatchDialogHide
import com.tangem.tap.common.feedback.SupportInfo
@ -21,7 +22,7 @@ object WalletActivationErrorDialog {
setPositiveButton(R.string.common_ok) { _, _ -> dialog.onConfirm() }
setNegativeButton(R.string.common_support) { _, _ ->
// changed on email support [REDACTED_TASK_KEY]
Analytics.send(Basic.ButtonSupport())
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Intro))
store.dispatch(GlobalAction.SendEmail(SupportInfo()))
}
setOnDismissListener { store.dispatchDialogHide() }

View file

@ -3,6 +3,7 @@ package com.tangem.tap.features.send.ui.dialogs
import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.tap.common.feedback.SendTransactionFailedEmail
import com.tangem.tap.common.redux.global.GlobalAction
@ -21,7 +22,7 @@ object RequestFeeErrorDialog {
setTitle(R.string.common_fee_error)
setMessage(context.getString(R.string.alert_failed_to_send_transaction_message, errorMessage))
setNegativeButton(R.string.details_row_title_contact_to_support) { _, _ ->
Analytics.send(Basic.ButtonSupport())
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Send))
store.dispatch(GlobalAction.SendEmail(SendTransactionFailedEmail(errorMessage)))
}
setPositiveButton(R.string.common_retry) { _, _ -> dialog.onRetry() }

View file

@ -6,6 +6,7 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.common.module.ModuleMessageConverter
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.sdk.extensions.localizedDescription
import com.tangem.tap.common.extensions.stripZeroPlainString
@ -33,7 +34,7 @@ object SendTransactionFailsDialog {
setTitle(R.string.alert_failed_to_send_transaction_title)
setMessage(context.getString(R.string.alert_failed_to_send_transaction_message, errorMessage))
setNeutralButton(R.string.details_row_title_contact_to_support) { _, _ ->
Analytics.send(Basic.ButtonSupport())
Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Send))
store.dispatch(GlobalAction.SendEmail(SendTransactionFailedEmail(errorMessage)))
}
setPositiveButton(R.string.common_cancel) { _, _ -> }

View file

@ -4,20 +4,20 @@ 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.tokens.impl.presentation.states.NetworkItemState
import kotlinx.collections.immutable.ImmutableCollection
@ -41,10 +41,7 @@ internal fun BriefNetworksList(
exit = fadeOut(),
) {
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
val iterator = networks.iterator()
var index = 0
while (iterator.hasNext()) {
val network = iterator.next()
for ((index, network) in networks.withIndex()) {
if (index < MAX_VISIBLE_BRIEF_ICONS) {
key(network.name + network.protocolName) {
BriefNetworkItem(model = network)
@ -59,7 +56,6 @@ internal fun BriefNetworksList(
break
}
}
index++
}
}
}
@ -109,8 +105,16 @@ internal fun BriefNetworkItem(model: NetworkItemState, modifier: Modifier = Modi
}
}
@Suppress("MagicNumber")
@Composable
internal fun HasMoreItem(moreCount: Int) {
val count = if (moreCount > 99) 99 else moreCount
val themeTextStyle = TangemTheme.typography.overline.copy(
letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp),
)
var textStyle by remember(themeTextStyle) { mutableStateOf(themeTextStyle) }
var readyToDraw by remember(themeTextStyle) { mutableStateOf(false) }
Box(
modifier = Modifier
.size(size = TangemTheme.dimens.size20)
@ -118,10 +122,20 @@ internal fun HasMoreItem(moreCount: Int) {
.background(TangemTheme.colors.control.unchecked),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = "+$moreCount",
style = TangemTheme.typography.overline,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(TangemTheme.dimens.spacing4)
.align(Alignment.Center)
.drawWithContent { if (readyToDraw) drawContent() },
text = "+$count",
style = textStyle,
overflow = TextOverflow.Clip,
onTextLayout = { textLayoutResult ->
if (textLayoutResult.didOverflowHeight) {
textStyle = textStyle.copy(fontSize = textStyle.fontSize * 0.9)
} else {
readyToDraw = true
}
},
)
}
}

View file

@ -18,10 +18,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockTy
import com.tangem.domain.wallets.legacy.unlockIfLockable
import com.tangem.tap.*
import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.intentHandler.handlers.BackgroundScanIntentHandler
import com.tangem.tap.features.intentHandler.handlers.WalletConnectLinkIntentHandler
@ -141,7 +138,7 @@ internal class WelcomeMiddleware {
val currency = ParamCardCurrencyConverter().convert(
value = scanResponse.cardTypesResolver,
)
Analytics.addContext(scanResponse)
if (currency != null) {
Analytics.send(
event = Basic.SignedIn(
@ -175,7 +172,7 @@ internal class WelcomeMiddleware {
)
store.inject(DaggerGraphState::scanCardProcessor).scan(
analyticsEvent = Basic.CardWasScanned(AnalyticsParam.ScannedFrom.SignIn),
analyticsSource = AnalyticsParam.ScreensSources.SignIn,
onSuccess = { scanResponse ->
scope.launch { onCardScanned(scanResponse) }
},

View file

@ -9,7 +9,7 @@ import com.tangem.blockchain.blockchains.cosmos.CosmosTransactionExtras
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.blockchains.hedera.HederaTransactionBuilder
import com.tangem.blockchain.blockchains.optimism.OptimismWalletManager
import com.tangem.blockchain.blockchains.optimism.EthereumOptimisticRollupWalletManager
import com.tangem.blockchain.blockchains.stellar.StellarMemo
import com.tangem.blockchain.blockchains.stellar.StellarTransactionExtras
import com.tangem.blockchain.blockchains.ton.TonTransactionExtras
@ -170,7 +170,7 @@ class TransactionManagerImpl(
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
val walletManager = getActualWalletManager(blockchain, derivationPath)
if (walletManager is EthereumWalletManager) {
if (walletManager is OptimismWalletManager) {
if (walletManager is EthereumOptimisticRollupWalletManager) {
return getFeeForOptimismBlockchain(
walletManager = walletManager,
amount = createAmount(amountToSend, currencyToSend, blockchain),
@ -285,7 +285,7 @@ class TransactionManagerImpl(
}
private suspend fun getFeeForOptimismBlockchain(
walletManager: OptimismWalletManager,
walletManager: EthereumOptimisticRollupWalletManager,
amount: Amount,
destinationAddress: String,
data: String?,

View file

@ -57,11 +57,13 @@ sealed class AnalyticsParam {
object BlockchainSdk : Error("Blockchain Sdk Error")
}
sealed class ScannedFrom(val value: String) {
object Introduction : ScannedFrom("Introduction")
object Main : ScannedFrom("Main")
object SignIn : ScannedFrom("Sign In")
object MyWallets : ScannedFrom("My Wallets")
sealed class ScreensSources(val value: String) {
data object Settings : ScreensSources("Settings")
data object Main : ScreensSources("Main")
data object SignIn : ScreensSources("Sign In")
data object Send : ScreensSources("Send")
data object Intro : ScreensSources("Introduction")
data object MyWallets : ScreensSources("My Wallets")
}
sealed class TxSentFrom(val value: String) {

View file

@ -7,7 +7,7 @@ sealed class Basic(
) : AnalyticsEvent("Basic", event, params, error) {
class CardWasScanned(
source: AnalyticsParam.ScannedFrom,
source: AnalyticsParam.ScreensSources,
) : Basic(
event = "Card Was Scanned",
params = mapOf(
@ -76,5 +76,10 @@ sealed class Basic(
class WalletOpened : Basic(event = "Wallet Opened")
class ButtonSupport : Basic("Request Support")
class ButtonSupport(source: AnalyticsParam.ScreensSources) : Basic(
event = "Request Support",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
),
)
}

View file

@ -146,8 +146,9 @@ internal class ConfigManagerImpl @Inject constructor() : ConfigManager {
blockBookRest = accessTokens.bitcoin?.blockBookRest,
),
algorand = GetBlockAccessToken(rest = accessTokens.algorand?.rest),
zkSyncEra = GetBlockAccessToken(jsonRpc = accessTokens.zksync?.jsonRPC),
polygonZkEvm = GetBlockAccessToken(jsonRpc = accessTokens.polygonZkevm?.jsonRPC),
zkSyncEra = GetBlockAccessToken(rest = accessTokens.zksync?.jsonRPC),
polygonZkEvm = GetBlockAccessToken(rest = accessTokens.polygonZkevm?.jsonRPC),
base = GetBlockAccessToken(rest = accessTokens.base?.jsonRPC),
)
}
}

View file

@ -70,6 +70,7 @@ data class GetBlockAccessTokens(
@Json(name = "algorand") val algorand: GetBlockToken?,
@Json(name = "polygon-zkevm") val polygonZkevm: GetBlockToken?,
@Json(name = "zksync") val zksync: GetBlockToken?,
@Json(name = "base") val base: GetBlockToken?,
)
@JsonClass(generateAdapter = true)

View file

@ -72,6 +72,12 @@ class AppPreferencesStore(
return this[key]?.let(adapter::fromJson).orEmpty()
}
/** Get set of data [T] by string [key] */
inline fun <reified T> MutablePreferences.getObjectSet(key: Preferences.Key<String>): Set<T>? {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
return this[key]?.let(adapter::fromJson)
}
/**
* Set data [T] by string [key] to [MutablePreferences]
*
@ -97,4 +103,10 @@ class AppPreferencesStore(
this[key] = adapter.toJson(value)
}
/** Sets set of data [T] by string [key] to [MutablePreferences] */
inline fun <reified T> MutablePreferences.setObjectSet(key: Preferences.Key<String>, value: Set<T>) {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
this[key] = adapter.toJson(value)
}
}

View file

@ -69,6 +69,16 @@ object PreferencesKeys {
val APP_LOGS_KEY by lazy { stringPreferencesKey(name = "app_logs") }
val POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY by lazy {
stringPreferencesKey(name = "POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX")
}
val POLKADOT_HEALTH_CHECKED_RESET_ACCOUNTS_KEY by lazy {
stringPreferencesKey(name = "POLKADOT_HEALTH_CHECKED_RESET_ACCOUNTS")
}
val POLKADOT_HEALTH_CHECKED_IMMUTABLE_ACCOUNTS_KEY by lazy {
stringPreferencesKey(name = "POLKADOT_HEALTH_CHECKED_IMMUTABLE_ACCOUNTS")
}
val SEND_TAP_HELP_PREVIEW_KEY by lazy { booleanPreferencesKey(name = "sendTapHelpPreview") }
val WAS_APPLICATION_STOPPED_KEY by lazy { booleanPreferencesKey(name = "applicationStopped") }

View file

@ -128,6 +128,15 @@ suspend inline fun <reified V> AppPreferencesStore.getObjectMap(key: Preferences
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
/** Get set of data [T] by string [key], or empty if data is not found */
suspend inline fun <reified T> AppPreferencesStore.getObjectSetSync(key: Preferences.Key<String>): Set<T> {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)

View file

@ -9,7 +9,7 @@
},
{
"name": "REDESIGNED_SEND_SCREEN_ENABLED",
"version": "undefined"
"version": "5.9.0"
},
{
"name": "LOCAL_USER_LOGS_ENABLED",
@ -21,7 +21,7 @@
},
{
"name": "WC_SOLANA_TX_SIGN_ENABLED",
"version": "5.9.0"
"version": "5.11.0"
},
{
"name": "TOKEN_LIST_LCE_ENABLED",

View file

@ -2,5 +2,9 @@ package com.tangem.core.navigation
interface StateDialog {
object ScanFailsDialog : StateDialog
data class ScanFailsDialog(val source: ScanFailsSource) : StateDialog
enum class ScanFailsSource {
MAIN, SIGN_IN, SETTINGS, INTRO;
}
}

View file

@ -79,7 +79,7 @@
<string name="common_explore_transaction_history">Посмотреть историю транзакций</string>
<string name="common_explorer">Обозреватель</string>
<string name="common_fee_label">Комиссия</string>
<string name="common_fee_selector_footer">Сетевые комиссии это плата пользователя за обработку и подтверждение транзакций. Размер комиссии зависит от нагрузки на сеть, объема транзакции и приоритета исполнения. %s</string>
<string name="common_fee_selector_footer">Сетевые комиссии это плата пользователя за обработку и подтверждение транзакций. Размер комиссии зависит от нагрузки на сеть, объема транзакции и приоритета исполнения. %s</string>
<string name="common_fee_selector_option_custom">Свое</string>
<string name="common_fee_selector_option_fast">Быстро</string>
<string name="common_fee_selector_option_market">По рынку</string>
@ -310,7 +310,7 @@
<string name="onboarding_exit_alert_message">В этом случае вам будет необходимо начать процесс заново.</string>
<string name="onboarding_exit_alert_title">Вы хотите выйти из процесса активации?</string>
<string name="onboarding_getting_started">Подготовка</string>
<string name="onboarding_linking_error_card_with_wallets">Другой кошелек уже был создан на карте, которую вы пытаетесь добавить. Хотите сбросить его и использовать карту для бэкапа?</string>
<string name="onboarding_linking_error_card_with_wallets">Другой кошелек уже был создан на карте, которую вы пытаетесь добавить. Если на нем есть средства, пожалуйста сначала выведите их, а затем сделайте сброс до заводских настроек и используйте как резервную.</string>
<string name="onboarding_navbar_title_creating_backup">Резервная копия</string>
<string name="onboarding_seed_button_read_more">Прочитать о seed-фразе</string>
<plurals name="onboarding_seed_generate_message_words_count">
@ -426,12 +426,12 @@
<string name="scan_card_settings_title">Приготовьте свою карту</string>
<string name="send_additional_field_already_included">Уже содержится в введенном адресе</string>
<string name="send_alert_fee_coverage_subract_text">Вычесть</string>
<string name="send_alert_fee_coverage_title">Недостаточно средств для покрытия комиссии сети. Вычесть комиссию %s из отправляемой сумму?</string>
<string name="send_alert_fee_coverage_title">Недостаточно средств для покрытия комиссии сети. Вычесть недостающую сумму для покрытия комиссии из отправляемой суммы?</string>
<string name="send_alert_fee_too_high_text">Сумма комиссии в %s раз превышает рекомендованную. Убедитесь, что указанная комиссия верна.</string>
<string name="send_alert_fee_too_low_text">Вы указали комиссию ниже рекомендуемой, это может привести к задержке исполнения вашей транзакции. Продолжить?</string>
<string name="send_alert_transaction_failed_text">Причина: %1$s\nКод: %2$s</string>
<string name="send_alert_transaction_failed_title">Транзакция не выполнена</string>
<string name="send_amount_label">Сумма</string>
<string name="send_confirm_label">Подтверждение</string>
<string name="send_date_format">%1$s, %2$s</string>
<string name="send_destination_hint_address">Адрес</string>
<string name="send_destination_tag_field">Код назначения</string>
@ -487,6 +487,7 @@
<string name="send_recipient_label">Отправить</string>
<string name="send_recipient_memo_footer">Мемо/ Код назначения - это код, разделяющий транзакции к общему получателю в сети криптовалют. Внимание: отсутствие мемо может привести к потере средств.</string>
<string name="send_recipient_wallets_title">Мои кошельки</string>
<string name="send_satoshi_per_byte_text">Это способ измерения комиссии за отправку биткоин-транзакции. Он указывает на количество самой маленькой единицы биткоина (сатоши) за каждый байт данных в транзакции. Чем выше это число, тем быстрее будет обработана транзакция сетью.</string>
<string name="send_sending">Отправка</string>
<string name="send_summary_tap_hint">Нажмите на любое поле, чтобы изменить его</string>
<string name="send_summary_title">Отправка %s</string>
@ -536,7 +537,8 @@
<string name="toast_balances_hidden">Балансы скрыты</string>
<string name="toast_balances_shown">Балансы показаны</string>
<string name="toast_undo">Отменить</string>
<string name="token_button_unavailability_reason_buy_unavailable">В данный момент покупка монеты %s недоступна. Но мы работаем над её добавлением.”</string>
<string name="token_button_unavailability_generic_description">Выбранная операция в данный момент недоступна. Попробуйте позже.</string>
<string name="token_button_unavailability_reason_buy_unavailable">В данный момент покупка монеты %s недоступна. Но мы работаем над её добавлением.</string>
<string name="token_button_unavailability_reason_empty_balance">У вас нет средств для отправки. Пополните счет, чтобы иметь возможность отправить с него средства.</string>
<string name="token_button_unavailability_reason_no_quotes">Выбранная операция в данный момент недоступна. Попробуйте позже.</string>
<string name="token_button_unavailability_reason_not_exchangeable">Обмен %s не доступен. Но мы работаем над его добавлением.</string>
@ -547,6 +549,7 @@
<string name="token_details_hide_alert_title">Скрыть %s</string>
<string name="token_details_hide_token">Скрыть токен</string>
<string name="token_details_token_type_subtitle">%1$s токен в сети %%image%% %2$s</string>
<string name="token_details_token_type_subtitle_no_standard">Токен в сети %%image%% %1$s</string>
<string name="token_details_unable_hide_alert_message">Токен %1$s является основной валютой в сети %2$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети.</string>
<string name="token_details_unable_hide_alert_title">Невозможно скрыть %s</string>
<string name="token_swap_changelly_promotion_message">Обменивайте этот токен на другие с %1$s комиссии за обслуживание с %2$s по %3$s февраля.</string>

View file

@ -310,7 +310,7 @@
<string name="onboarding_exit_alert_message">In this case, you will need to start from the beginning.</string>
<string name="onboarding_exit_alert_title">Do you want to exit the activation process?</string>
<string name="onboarding_getting_started">Getting started</string>
<string name="onboarding_linking_error_card_with_wallets">Another wallet has already been created on the card you\'re trying to add. Do you want to reset it and use the card for a new wallet?</string>
<string name="onboarding_linking_error_card_with_wallets">Another wallet has already been created on the card you\'re trying to add. If you have funds in this wallet, please withdraw it and then reset this card and add it as a backup.</string>
<string name="onboarding_navbar_title_creating_backup">Creating a backup</string>
<string name="onboarding_seed_button_read_more">Read more about seed phrase</string>
<plurals name="onboarding_seed_generate_message_words_count">
@ -421,12 +421,12 @@
<string name="scan_card_settings_title">Get your card ready!</string>
<string name="send_additional_field_already_included">Already included in the entered address</string>
<string name="send_alert_fee_coverage_subract_text">Subtract</string>
<string name="send_alert_fee_coverage_title">Not enough funds to cover the network commission. Subtract the commission %s from the amount sent?</string>
<string name="send_alert_fee_coverage_title">Not enough funds to cover the network fee. Do you want to subtract the amount required to cover the fee?</string>
<string name="send_alert_fee_too_high_text">The commission amount is %s times the recommended amount. Make sure that the custom settings are correct.</string>
<string name="send_alert_fee_too_low_text">You specified a commission below the recommended amount, which could cause a delay in your transaction. Continue?</string>
<string name="send_alert_transaction_failed_text">Reason: %1$s\nCode: %2$s</string>
<string name="send_alert_transaction_failed_title">The transaction is not completed</string>
<string name="send_amount_label">Amount</string>
<string name="send_confirm_label">Confirm</string>
<string name="send_date_format">%1$s, %2$s</string>
<string name="send_destination_hint_address">Address</string>
<string name="send_destination_tag_field">Destination Tag</string>
@ -482,6 +482,8 @@
<string name="send_recipient_label">Send to</string>
<string name="send_recipient_memo_footer">A Memo/Destination Tag is a unique ID for differentiating transactions sent to the same recipient on the same network. Caution: Omitting a memo may lead to misplaced funds</string>
<string name="send_recipient_wallets_title">My wallets</string>
<string name="send_satoshi_per_byte_text">The fee for a Bitcoin transaction is measured by the number of the smallest Bitcoin unit (Satoshi) per byte of data. The higher this number, the faster the transaction will be processed.</string>
<string name="send_satoshi_per_byte_title">Satoshi per vbyte</string>
<string name="send_sending">Sending...</string>
<string name="send_summary_tap_hint">Tap any field to change it</string>
<string name="send_summary_title">Send %s</string>
@ -532,6 +534,7 @@
<string name="toast_balances_hidden">Balances hidden</string>
<string name="toast_balances_shown">Balances shown</string>
<string name="toast_undo">Undo</string>
<string name="token_button_unavailability_generic_description">This operation is currently unavailable. Please try again later.</string>
<string name="token_button_unavailability_reason_buy_unavailable">The purchase of the %s is currently unavailable. But we are working on adding it.</string>
<string name="token_button_unavailability_reason_empty_balance">You do not have funds to send. Top up your account to be able to send funds from it.</string>
<string name="token_button_unavailability_reason_no_quotes">This operation is currently unavailable. Please try again later.</string>
@ -543,6 +546,7 @@
<string name="token_details_hide_alert_title">Hide %s</string>
<string name="token_details_hide_token">Hide token</string>
<string name="token_details_token_type_subtitle">%1$s token in %%image%% %2$s network</string>
<string name="token_details_token_type_subtitle_no_standard">Token in %%image%% %1$s network</string>
<string name="token_details_unable_hide_alert_message">The %1$s token is the main currency on the %2$s network and cannot be hidden as long as you have other tokens on this network in the list.</string>
<string name="token_details_unable_hide_alert_title">Unable to hide %s</string>
<string name="token_swap_changelly_promotion_message">Exchange this token for another at %1$s service fees from February %2$s-%3$s.</string>

View file

@ -71,7 +71,16 @@ fun SimpleTextField(
}
}
var lastTextValue by remember(value) { mutableStateOf(value) }
var lastTextValue by remember(value) {
val isSelectionLastIndex = textFieldValueState.selection.end == textFieldValueState.text.lastIndex
if (textFieldValueState.text.isBlank() || isSelectionLastIndex) {
textFieldValueState = textFieldValueState.copy(
text = value,
selection = getValueRange(value),
)
}
mutableStateOf(value)
}
CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) {
BasicTextField(
@ -82,9 +91,7 @@ fun SimpleTextField(
val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text
lastTextValue = newTextFieldValueState.text
if (stringChangedSinceLastInvocation) {
onValueChange(newTextFieldValueState.text)
}
if (stringChangedSinceLastInvocation) onValueChange(newTextFieldValueState.text)
},
textStyle = textStyle.copy(color = color),
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),

View file

@ -7,7 +7,6 @@ import androidx.compose.ui.text.input.VisualTransformation
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.defaultFormat
import com.tangem.core.ui.utils.formatWithThousands
import com.tangem.core.ui.utils.parseToBigDecimal
import java.text.DecimalFormat
class AmountVisualTransformation(
@ -23,21 +22,16 @@ class AmountVisualTransformation(
decimals,
)
formattedAmount = formattedAmount.ifEmpty { decimalFormat.defaultFormat() }
val decimalValue = text.text.parseToBigDecimal(decimals)
val formattedText = if (formattedAmount.isNotEmpty() && symbol != null) {
AnnotatedString(
if (currencyCode != null) {
BigDecimalFormatter.formatFiatAmount(
fiatAmount = decimalValue,
BigDecimalFormatter.formatFiatEditableAmount(
fiatAmount = formattedAmount,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = symbol,
)
} else {
BigDecimalFormatter.formatCryptoAmountUncapped(
cryptoAmount = decimalValue,
cryptoSymbol = symbol,
decimals = decimals,
)
BigDecimalFormatter.formatWithSymbol(formattedAmount, symbol)
},
)
} else {

View file

@ -57,6 +57,7 @@ fun InputRowEnterInfoAmount(
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
showDivider: Boolean = false,
isReadOnly: Boolean = false,
) {
DividerContainer(
modifier = modifier,
@ -83,6 +84,7 @@ fun InputRowEnterInfoAmount(
),
onValueChange = onValueChange,
color = textColor,
isEnabled = !isReadOnly,
textStyle = TangemTheme.typography.body2,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,

View file

@ -1,8 +1,10 @@
package com.tangem.core.ui.utils
import com.tangem.domain.tokens.model.CryptoCurrency
import timber.log.Timber
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
@ -38,30 +40,6 @@ object BigDecimalFormatter {
}
}
fun formatCryptoAmountUncapped(
cryptoAmount: BigDecimal?,
cryptoSymbol: String,
decimals: Int,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = decimals
minimumFractionDigits = minOf(2, decimals)
isGroupingUsed = true
roundingMode = RoundingMode.DOWN
}
return formatter.format(cryptoAmount).let {
if (cryptoSymbol.isEmpty()) {
it
} else {
it + "\u2009$cryptoSymbol"
}
}
}
fun formatCryptoAmount(
cryptoAmount: BigDecimal?,
cryptoCurrency: CryptoCurrency,
@ -90,6 +68,26 @@ object BigDecimalFormatter {
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
fun formatFiatEditableAmount(
fiatAmount: String?,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
if (fiatAmount == null) return EMPTY_BALANCE_SIGN
val formatterCurrency = getCurrency(fiatCurrencyCode)
val numberFormatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency
}
val formatter = requireNotNull(numberFormatter as? DecimalFormat) {
Timber.e("NumberFormat is null")
return EMPTY_BALANCE_SIGN
}
return "${formatter.positivePrefix}$fiatAmount${formatter.positiveSuffix}"
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
fun formatPercent(
percent: BigDecimal,
useAbsoluteValue: Boolean,
@ -107,6 +105,8 @@ object BigDecimalFormatter {
return formatter.format(value)
}
fun formatWithSymbol(amount: String, symbol: String) = "$amount\u2009$symbol"
private fun getCurrency(code: String): Currency {
return runCatching { Currency.getInstance(code) }
.getOrElse { e ->

View file

@ -11,6 +11,9 @@ import java.util.Locale
private const val TEXT_CHUNK_THOUSAND = 3
private const val POINT_SEPARATOR = '.'
private const val COMMA_SEPARATOR = ','
private const val SCIENTIFIC_NOTATION = 'e'
const val DECIMAL_SEPARATOR_LIMIT = 1
@Composable
fun rememberDecimalFormat(): DecimalFormat {
@ -59,7 +62,7 @@ fun DecimalFormat.getValidatedNumberWithFixedDecimals(text: String, decimals: In
return if (filteredChars.count { it == decimalSeparator } == 1) {
val beforeDecimal = filteredChars.substringBefore(decimalSeparator)
val afterDecimal = filteredChars.substringAfter(decimalSeparator)
beforeDecimal + decimalSeparator + afterDecimal.take(decimals)
decimals.getWithIntegerDecimals(beforeDecimal, decimalSeparator, afterDecimal)
}
// If there is no dot, just take all digits
else {
@ -82,7 +85,7 @@ fun DecimalFormat.formatWithThousands(text: String, decimals: Int): String {
.joinToString(thousandsSeparator.toString())
.reversed()
val afterDecimal = localizedText.substringAfter(decimalSeparator)
beforeDecimal + decimalSeparator + afterDecimal.take(decimals)
decimals.getWithIntegerDecimals(beforeDecimal, decimalSeparator, afterDecimal)
}
// If there is no dot, just take all digits
else {
@ -150,4 +153,51 @@ fun BigDecimal.parseBigDecimal(decimals: Int, roundingMode: RoundingMode = Round
} catch (e: Exception) {
""
}
}
/**
* Universal amount string parser to [BigDecimal]
* Able to parse values with only ONE separator, assuming separator is COMMA.
* Otherwise returns null.
*/
fun String.parseBigDecimalOrNull() = runCatching {
// Filtering value containing more than one either grouping or decimal separator.
// We assume there will be only decimal separator, otherwise parsing will fail.
// Step 1. Exclude formatted (100,000.0) except scientific notation (100.000e10)
val excludeFormatted = this.count {
!it.isDigit() && !it.equals(SCIENTIFIC_NOTATION, ignoreCase = true)
} > DECIMAL_SEPARATOR_LIMIT
// Step 2. Exclude wrong scientific notation (100e100e100)
val excludeWrongScientific = this.count {
it.equals(SCIENTIFIC_NOTATION, ignoreCase = true)
} > DECIMAL_SEPARATOR_LIMIT
if (excludeFormatted || excludeWrongScientific) return null
// An attempt to parse value with POINT decimal separator
val parsed = this.toBigDecimalOrNull()
if (parsed == null) {
// If parsing with POINT separator fails trying to parse with COMMA separator
val decimalFormatSymbol = DecimalFormatSymbols().apply {
decimalSeparator = COMMA_SEPARATOR
}
val decimalFormat = DecimalFormat().apply {
decimalFormatSymbols = decimalFormatSymbol
isParseBigDecimal = true
}
// Return either number or null if fails
decimalFormat.parse(this) as? BigDecimal
} else {
// If parsing with POINT separator succeeds return number
parsed
}
}.getOrNull()
private fun Int.getWithIntegerDecimals(before: String, separator: Char, after: String): String = if (this == 0) {
before
} else {
before + separator + after.take(this)
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.qrscanning.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
@ -44,7 +45,7 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
when (it.key) {
Parameter.Amount -> {
// According to BIP-0021, the value is specified in decimals. No conversion needed
result.amount = it.value.toBigDecimalOrNull()
result.amount = it.value.parseBigDecimalOrNull()
}
Parameter.Message,
Parameter.Memo,
@ -52,16 +53,17 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
result.memo = URLDecoder.decode(it.value, "UTF-8")
}
Parameter.Address -> {
// If 'address' parameter is exists, then currency must be TOKEN.
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
// Overrides destination address for token transfers (ERC-681)
if (cryptoCurrency is CryptoCurrency.Token) {
// `address` parameter is used only if the contract address, encoded in the QR,
// matches the contract address of the token.
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routine
if (cryptoCurrency.contractAddress.equals(address, ignoreCase = true)) {
result.address = it.value
} else {
return QrResult()
}
// `address` parameter is used only if the contract address, encoded in the QR,
// matches the contract address of the token.
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin
if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) {
result.address = it.value
} else {
return QrResult()
}
}
Parameter.Value,
@ -69,7 +71,7 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
-> {
// Extra convert parses scientific notation to decimal
// This is necessary to be able comparing BigDecimal values
result.amount = it.value.toBigDecimalOrNull()
result.amount = it.value.parseBigDecimalOrNull()
?.toPlainString()?.toBigDecimalOrNull()
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
}

View file

@ -152,11 +152,6 @@ internal class DefaultQrScanningEventsRepositoryTest {
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2$function?$addressParam=$addressParamValue",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$someAmountParamValue",
QrResult(address = address2),
@ -177,6 +172,11 @@ internal class DefaultQrScanningEventsRepositoryTest {
QrResult(address = address2, amount = BigDecimal("0.000000023")),
cryptoCurrency,
)
negativeCase(
"$garbage$schema2:$address2$function?$addressParam=$addressParamValue",
QrResult(address = address2),
cryptoCurrency,
)
}
@Test
@ -232,11 +232,21 @@ internal class DefaultQrScanningEventsRepositoryTest {
QrResult(address = address2, amount = BigDecimal("2300")),
tokenCryptoCurrency,
)
positiveCase(
"$address4?$addressParam=$addressParamValue",
QrResult(address = addressParamValue),
tokenCryptoCurrency,
)
negativeCase(
"$address2?$someParam=$someParamValue&$amountParam=$amountParamValue",
QrResult(address = address2, amount = BigDecimal("123.123"), memo = memoParamValueUtf8),
tokenCryptoCurrency,
)
negativeCase(
"$address4?$addressParam=$addressParamValue",
QrResult(address = addressParamValue),
cryptoCurrency,
)
}
private fun positiveCase(input: String, expected: QrResult, cryptoCurrency: CryptoCurrency) {

View file

@ -119,4 +119,18 @@ internal object TokensDataModule {
fun provideCurrencyChecksRepository(walletManagersFacade: WalletManagersFacade): CurrencyChecksRepository {
return DefaultCurrencyChecksRepository(walletManagersFacade = walletManagersFacade)
}
@Provides
@Singleton
fun providePolkadotAccountHealthCheckRepository(
walletManagersFacade: WalletManagersFacade,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): PolkadotAccountHealthCheckRepository {
return DefaultPolkadotAccountHealthCheckRepository(
walletManagersFacade = walletManagersFacade,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
}
}

View file

@ -27,6 +27,7 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import timber.log.Timber
@Suppress("LongParameterList")
internal class DefaultNetworksRepository(
private val networksStatusesStore: NetworksStatusesStore,
private val walletManagersFacade: WalletManagersFacade,

View file

@ -0,0 +1,179 @@
package com.tangem.data.tokens.repository
import androidx.datastore.preferences.core.Preferences
import com.tangem.blockchain.blockchains.polkadot.AccountCheckProvider
import com.tangem.blockchain.blockchains.polkadot.network.accounthealthcheck.ExtrinsicListItemResponse
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys.POLKADOT_HEALTH_CHECKED_IMMUTABLE_ACCOUNTS_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.POLKADOT_HEALTH_CHECKED_RESET_ACCOUNTS_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY
import com.tangem.datasource.local.preferences.utils.getObjectMap
import com.tangem.datasource.local.preferences.utils.getObjectSetSync
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.set
internal class DefaultPolkadotAccountHealthCheckRepository(
private val walletManagersFacade: WalletManagersFacade,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : PolkadotAccountHealthCheckRepository {
private val hasImmortalTransaction = MutableSharedFlow<Pair<String, Boolean>>()
private val hasResetTransaction = MutableSharedFlow<Pair<String, Boolean>>()
private val mutex = Mutex()
private val mutexes = ConcurrentHashMap<String, Mutex>()
override suspend fun runCheck(userWalletId: UserWalletId, network: Network) {
// Run Polkadot account health check
if (Blockchain.fromId(network.id.value) != Blockchain.Polkadot) return
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
val address = requireNotNull(walletManager?.wallet?.address) {
Timber.e("Address is null")
return
}
val accountCheckProvider = requireNotNull(walletManager as? AccountCheckProvider) {
Timber.e("Unable to cast wallet manager to AccountCheckProvider")
return
}
// use a separate mutexForKey for each key to avoid multiple calls block() to the same key
// also used mutex to safe create mutexForKey, otherwise it can lead to multiple calls for the same key
val mutexForKey = mutex.withLock { mutexes.getOrPut(address) { Mutex() } }
mutexForKey.withLock {
withContext(dispatchers.io) {
checkHasReset(accountCheckProvider, address)
checkHasImmortal(accountCheckProvider, address)
}
}
}
override fun subscribeToHasImmortalResults() = hasImmortalTransaction.asSharedFlow()
override fun subscribeToHasResetResults() = hasResetTransaction.asSharedFlow()
private suspend fun checkHasReset(polkadotManager: AccountCheckProvider, address: String) {
val checkedAddresses = appPreferencesStore.getObjectSetSync<String>(POLKADOT_HEALTH_CHECKED_RESET_ACCOUNTS_KEY)
if (checkedAddresses.contains(address)) return
runCatching {
val accountInfo = requireNotNull(polkadotManager.getAccountInfo().account) {
Timber.e("Account info is null")
}
val nonce = accountInfo.nonce
val extrinsicCount = accountInfo.countExtrinsic
// Account was reset
if (nonce != null && extrinsicCount != null) {
val hasReset = nonce < extrinsicCount
updateChecked(address, POLKADOT_HEALTH_CHECKED_RESET_ACCOUNTS_KEY)
hasResetTransaction.emit(address to hasReset)
}
}.onFailure {
if ((it as? BlockchainSdkError.CustomError)?.customMessage == ACCOUNT_NOT_FOUND) {
updateChecked(address, POLKADOT_HEALTH_CHECKED_RESET_ACCOUNTS_KEY)
}
}
}
private suspend fun checkHasImmortal(accountCheckerProvider: AccountCheckProvider, address: String) {
val checkedAddresses =
appPreferencesStore.getObjectSetSync<String>(POLKADOT_HEALTH_CHECKED_IMMUTABLE_ACCOUNTS_KEY)
if (checkedAddresses.contains(address)) return
runCatching {
do {
// Getting batch of extrinsics to check
val lastChecked = appPreferencesStore.getObjectMap<Long>(POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY)
val lastExtrinsic = lastChecked[address]
val extrinsicListResult = accountCheckerProvider.getExtrinsicList(afterExtrinsicId = lastExtrinsic)
extrinsicListResult.extrinsic
?.forEach { tx ->
// Checking extrinsic one by one
if (checkTx(tx, address, accountCheckerProvider)) return
}
} while (!extrinsicListResult.extrinsic.isNullOrEmpty())
// We checked all transactions up to current moment and did not found an `immortal` transaction
hasImmortalTransaction.emit(address to false)
updateChecked(address, POLKADOT_HEALTH_CHECKED_IMMUTABLE_ACCOUNTS_KEY)
clearLastCheckedTransaction(address)
}
}
private suspend fun checkTx(
tx: ExtrinsicListItemResponse,
address: String,
accountCheckerProvider: AccountCheckProvider,
): Boolean {
val hash = tx.hash
val id = tx.id
if (hash != null && id != null) {
val details = accountCheckerProvider.getExtrinsicDetail(hash)
// We found an `immortal` transaction
if (details.lifetime == null) {
hasImmortalTransaction.emit(address to true)
updateChecked(address, POLKADOT_HEALTH_CHECKED_IMMUTABLE_ACCOUNTS_KEY)
clearLastCheckedTransaction(address)
return true
}
// Saving last checked transaction
updateLastCheckedTransaction(address, id)
}
return false
}
private suspend fun updateLastCheckedTransaction(address: String, txId: Long) {
appPreferencesStore.editData {
val savedList = it.getObjectMap<Long>(POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY)
val updatedList = savedList.toMutableMap()
updatedList[address] = txId
it.setObjectMap(POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY, updatedList)
}
}
private suspend fun clearLastCheckedTransaction(address: String) {
appPreferencesStore.editData { mutablePreferences ->
val savedList = mutablePreferences.getObjectMap<Long>(POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY)
val updatedList = savedList.toMutableMap()
updatedList.remove(address)
mutablePreferences.setObjectMap(POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY, updatedList)
}
}
private suspend fun updateChecked(address: String, key: Preferences.Key<String>) {
appPreferencesStore.editData { mutablePreferences ->
val savedList = mutablePreferences.getObjectSet<String>(key)
val updatedList = savedList?.toMutableSet() ?: mutableSetOf()
updatedList.add(address)
if (updatedList.isNotEmpty()) {
mutablePreferences.setObjectSet(
key,
updatedList.toSet(),
)
} else {
mutablePreferences.remove(key)
}
}
}
private companion object {
const val ACCOUNT_NOT_FOUND = "Record Not Found"
}
}

View file

@ -21,6 +21,7 @@ import com.tangem.domain.walletmanager.utils.SdkPageConverter
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
import timber.log.Timber
class DefaultTxHistoryRepository(
private val cacheRegistry: CacheRegistry,
@ -68,6 +69,7 @@ class DefaultTxHistoryRepository(
return pager.flow
}
@Deprecated("Replace with getTxExploreUrl [UserWalletId, Network] instead")
override fun getTxExploreUrl(txHash: String, networkId: Network.ID): String {
val blockchain = Blockchain.fromId(networkId.value)
return when (val txExploreState = blockchain.getExploreTxUrl(txHash)) {
@ -76,22 +78,40 @@ class DefaultTxHistoryRepository(
}
}
override suspend fun getTxExploreUrl(userWalletId: UserWalletId, network: Network): String {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
network = network,
)
val lastTxHash = walletManager?.wallet?.recentTransactions?.last()?.hash.orEmpty()
return when (val txExploreState = blockchain?.getExploreTxUrl(lastTxHash)) {
is TxExploreState.Url -> txExploreState.url
else -> ""
}
}
override suspend fun getFixedSizeTxHistoryItems(
userWalletId: UserWalletId,
currency: CryptoCurrency,
pageSize: Int,
refresh: Boolean,
): List<TxHistoryItem> {
cacheRegistry.invokeOnExpire(
key = getTxHistoryPageKey(currency, userWalletId, Page.Initial),
skipCache = refresh,
block = { fetchFixedSizeTxHistoryItems(userWalletId, currency, pageSize) },
)
val txs = txHistoryItemsStore.getSyncOrNull(
key = TxHistoryItemsStore.Key(userWalletId, currency),
page = Page.Initial,
)?.items
return txs ?: emptyList()
return try {
cacheRegistry.invokeOnExpire(
key = getTxHistoryPageKey(currency, userWalletId, Page.Initial),
skipCache = refresh,
block = { fetchFixedSizeTxHistoryItems(userWalletId, currency, pageSize) },
)
val txs = txHistoryItemsStore.getSyncOrNull(
key = TxHistoryItemsStore.Key(userWalletId, currency),
page = Page.Initial,
)?.items
txs ?: emptyList()
} catch (e: Throwable) {
Timber.e(e, "Unable to load the transaction history for the requested page: ${Page.Initial}")
emptyList()
}
}
private fun getTxHistoryPageKey(currency: CryptoCurrency, userWalletId: UserWalletId, page: Page): String {

View file

@ -2,7 +2,7 @@ package com.tangem.domain.card
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.models.scan.ScanResponse
interface ScanCardProcessor {
@ -13,7 +13,7 @@ interface ScanCardProcessor {
): CompletionResult<ScanResponse>
suspend fun scan(
analyticsEvent: AnalyticsEvent? = null,
analyticsSource: AnalyticsParam.ScreensSources,
cardId: String? = null,
onProgressStateChange: suspend (showProgress: Boolean) -> Unit = {},
onWalletNotCreated: suspend () -> Unit = {},

View file

@ -399,12 +399,27 @@ class DemoConfig {
"AB02000000048063",
"AB02000000023736",
"AB02000000058187",
"AB02000000000007",
"AC03000000076229",
"AF04000000000118",
// Wallet 2
"AF04000000012006",
"AF04000000012014",
"AF04000000012022",
"AF04000000012030",
"AF15000000257889",
"AF15000000257897",
"AF15000000637809",
"AF15000000640282",
"AF15000001187424",
"AF15000001187408",
"AF15000001187416",
"AF15000001195781",
"AF15000001195773",
"AF15000001195799",
"AF19000000000038",
"AC19000000000064",
)
@Suppress("ClassOrdering")

View file

@ -197,7 +197,7 @@ class GetCurrencyWarningsUseCase(
coinCurrency = coinStatus.currency,
)
}
feePaidCurrency is FeePaidCurrency.SameCurrency && !tokenStatus.value.amount.isZero() -> {
feePaidCurrency is FeePaidCurrency.SameCurrency && tokenStatus.value.amount.isZero() -> {
CryptoCurrencyWarning.BalanceNotEnoughForFee(
tokenCurrency = tokenStatus.currency,
coinCurrency = coinStatus.currency,

View file

@ -0,0 +1,11 @@
package com.tangem.domain.tokens
import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository
import kotlinx.coroutines.flow.Flow
class GetPolkadotCheckHasImmortalUseCase(
private val polkadotAccountHealthCheckRepository: PolkadotAccountHealthCheckRepository,
) {
operator fun invoke(): Flow<Pair<String, Boolean>> =
polkadotAccountHealthCheckRepository.subscribeToHasImmortalResults()
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.tokens
import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository
import kotlinx.coroutines.flow.Flow
class GetPolkadotCheckHasResetUseCase(
private val polkadotAccountHealthCheckRepository: PolkadotAccountHealthCheckRepository,
) {
operator fun invoke(): Flow<Pair<String, Boolean>> =
polkadotAccountHealthCheckRepository.subscribeToHasResetResults()
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.tokens
import arrow.core.Either
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository
import com.tangem.domain.wallets.models.UserWalletId
class RunPolkadotAccountHealthCheckUseCase(
private val polkadotAccountHealthCheckRepository: PolkadotAccountHealthCheckRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, Unit> = Either.catch {
polkadotAccountHealthCheckRepository.runCheck(userWalletId, network)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.tokens.repository
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
interface PolkadotAccountHealthCheckRepository {
suspend fun runCheck(userWalletId: UserWalletId, network: Network)
fun subscribeToHasImmortalResults(): Flow<Pair<String, Boolean>>
fun subscribeToHasResetResults(): Flow<Pair<String, Boolean>>
}

View file

@ -8,7 +8,7 @@ sealed class SendTransactionError {
data class DataError(val message: String?) : SendTransactionError()
data class NetworkError(val message: String?) : SendTransactionError()
data class NetworkError(val message: String?, val code: String?) : SendTransactionError()
data class BlockchainSdkError(val code: Int, val message: String?) : SendTransactionError()

View file

@ -67,7 +67,12 @@ class SendTransactionUseCase(
}
private fun handleError(result: SimpleResult.Failure): SendTransactionError {
if (ResultChecker.isNetworkError(result)) return SendTransactionError.NetworkError(result.error.message)
if (ResultChecker.isNetworkError(result)) {
return SendTransactionError.NetworkError(
code = result.error.message,
message = result.error.customMessage,
)
}
val error = result.error as? BlockchainSdkError ?: return SendTransactionError.UnknownError()
return when (error) {
is BlockchainSdkError.WrappedTangemError -> {

View file

@ -24,6 +24,9 @@ interface TxHistoryRepository {
fun getTxExploreUrl(txHash: String, networkId: Network.ID): String
/** Get transaction url in explorer via last transaction from wallet's recentTransactions list */
suspend fun getTxExploreUrl(userWalletId: UserWalletId, network: Network): String
@Throws(TxHistoryListError::class)
suspend fun getFixedSizeTxHistoryItems(
userWalletId: UserWalletId,

View file

@ -6,10 +6,12 @@ import arrow.core.raise.either
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.txhistory.models.TxStatusError
import com.tangem.domain.txhistory.repository.TxHistoryRepository
import com.tangem.domain.wallets.models.UserWalletId
class GetExplorerTransactionUrlUseCase(
private val repository: TxHistoryRepository,
) {
@Deprecated("Replace with invoke [UserWalletId, Network]")
operator fun invoke(txHash: String, networkId: Network.ID): Either<TxStatusError, String> {
return either {
catch(
@ -22,4 +24,17 @@ class GetExplorerTransactionUrlUseCase(
)
}
}
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<TxStatusError, String> {
return either {
catch(
block = {
repository.getTxExploreUrl(userWalletId, network).ifEmpty {
raise(TxStatusError.EmptyUrlError)
}
},
catch = { raise(TxStatusError.DataError(it)) },
)
}
}
}

View file

@ -33,4 +33,15 @@ class GetFixedTxHistoryItemsUseCase(
}
}.mapLeft { TxHistoryListError.DataError(it) }
}
suspend fun getSync(
userWalletId: UserWalletId,
currency: CryptoCurrency,
pageSize: Int = DEFAULT_PAGE_SIZE,
refresh: Boolean = false,
): Either<TxHistoryListError, List<TxHistoryItem>> {
return Either.catch {
repository.getFixedSizeTxHistoryItems(userWalletId, currency, pageSize, refresh)
}.mapLeft { TxHistoryListError.DataError(it) }
}
}

View file

@ -84,6 +84,11 @@ interface UserWalletsListManager {
*/
suspend fun get(userWalletId: UserWalletId): CompletionResult<UserWallet>
/**
* Indicates that the [UserWalletsListManager] supports [UserWalletsListManager.Lockable]
* */
fun isLockable(): Boolean
interface Lockable : UserWalletsListManager {
/**

View file

@ -6,12 +6,6 @@ import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* Indicates that the [UserWalletsListManager] implements [UserWalletsListManager.Lockable]
* */
val UserWalletsListManager.isLockable: Boolean
get() = this is UserWalletsListManager.Lockable
/**
* Indicates that the [UserWalletsListManager] is locked
*
@ -48,16 +42,6 @@ suspend fun UserWalletsListManager.unlockIfLockable(type: UnlockType = UnlockTyp
return asLockable()?.unlock(type) ?: CompletionResult.Failure(UserWalletsListError.UnableToUnlockUserWallets())
}
/**
* Call [UserWalletsListManager.Lockable.lock] if [UserWalletsListManager] implements [UserWalletsListManager.Lockable]
* or do nothing otherwise
*
* @see UserWalletsListManager.Lockable.lock
* */
fun UserWalletsListManager.lockIfLockable() {
asLockable()?.lock()
}
/**
* Safe cast [UserWalletsListManager] to [UserWalletsListManager.Lockable]
*
@ -65,5 +49,8 @@ fun UserWalletsListManager.lockIfLockable() {
* [UserWalletsListManager.Lockable] otherwise
* */
fun UserWalletsListManager.asLockable(): UserWalletsListManager.Lockable? {
return this as? UserWalletsListManager.Lockable
if (this.isLockable()) {
return this as? UserWalletsListManager.Lockable
}
return null
}

View file

@ -33,7 +33,7 @@ private fun CameraDeniedBottomSheet(content: CameraDeniedBottomSheetConfig) {
title = stringResource(id = R.string.qr_scanner_camera_denied_settings_button),
icon = R.drawable.ic_settings_24,
onItemsClick = {
val intent: Intent = Intent(
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
@ -47,7 +47,7 @@ private fun CameraDeniedBottomSheet(content: CameraDeniedBottomSheetConfig) {
)
SimpleSettingsRow(
title = stringResource(id = R.string.common_close),
icon = R.drawable.ic_close,
icon = R.drawable.ic_close_24,
onItemsClick = content.onCancelClick,
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing16),
)

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import arrow.core.getOrElse
@ -73,7 +74,8 @@ internal class SendFragment : ComposeFragment() {
SystemBarsEffect {
setSystemBarsColor(systemBarsColor)
}
SendScreen(viewModel.uiState, viewModel.stateRouter.currentState)
val currentState = viewModel.stateRouter.currentState.collectAsStateWithLifecycle()
SendScreen(viewModel.uiState, currentState.value)
}
override fun onDestroy() {
@ -90,9 +92,9 @@ internal class SendFragment : ComposeFragment() {
delay(QR_SCAN_DELAY)
// Delayed launch is needed in order for the UI to be drawn and to process the sent events.
// If do not use the delay, then etAmount error field is not displayed when
// If do not use the delay, then error field is not displayed when
// inserting an incorrect amount by shareUri
viewModel.onRecipientAddressScanned(it)
viewModel.onQrCodeScanned(it)
}
}
}

View file

@ -5,10 +5,11 @@ import com.tangem.core.ui.extensions.TextReference
data class SendRecipientListContent(
val id: String,
val title: TextReference,
val subtitle: TextReference,
val title: TextReference = TextReference.EMPTY,
val subtitle: TextReference = TextReference.EMPTY,
val timestamp: TextReference? = null,
val subtitleEndOffset: Int = 0,
@DrawableRes val subtitleIconRes: Int? = null,
val isVisible: Boolean = true,
val isLoading: Boolean = false,
)

View file

@ -58,13 +58,22 @@ internal sealed class SendAlertState {
override val confirmButtonText: TextReference = resourceReference(R.string.common_continue)
}
data class FeeTooHigh(
val times: String,
override val onConfirmClick: () -> Unit,
) : SendAlertState() {
override val title: TextReference? = null
override val message: TextReference =
resourceReference(id = R.string.send_alert_fee_too_high_text, wrappedList(times))
override val confirmButtonText: TextReference = resourceReference(R.string.common_continue)
}
data class FeeCoverage(
val amount: String,
override val onConfirmClick: (() -> Unit),
) : SendAlertState() {
override val title: TextReference? = null
override val message: TextReference =
resourceReference(id = R.string.send_alert_fee_coverage_title, wrappedList(amount))
resourceReference(id = R.string.send_alert_fee_coverage_title)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.send_alert_fee_coverage_subract_text)
}
@ -75,4 +84,12 @@ internal sealed class SendAlertState {
override val message: TextReference =
resourceReference(id = R.string.send_notification_invalid_reserve_amount_text)
}
data class FeeUnreachableError(
override val onConfirmClick: (() -> Unit),
) : SendAlertState() {
override val title: TextReference = resourceReference(R.string.send_fee_unreachable_error_title)
override val message: TextReference = resourceReference(R.string.send_fee_unreachable_error_text)
override val confirmButtonText = resourceReference(R.string.warning_button_refresh)
}
}

View file

@ -3,7 +3,7 @@ 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.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
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
@ -21,11 +21,15 @@ import java.math.BigDecimal
*/
internal class SendEventStateFactory(
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val clickIntents: SendClickIntents,
private val feeStateFactory: FeeStateFactory,
) {
private val sendTransactionErrorConverter by lazy(LazyThreadSafetyMode.NONE) {
SendTransactionAlertConverter(clickIntents)
SendTransactionAlertConverter(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
clickIntents = clickIntents,
)
}
fun onConsumeEventState(): SendUiState {
@ -45,18 +49,10 @@ internal class SendEventStateFactory(
}
fun getFeeCoverageAlert(onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
val amount = state.feeState?.fee?.amount ?: return state
val feeAmount = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amount.value,
cryptoCurrency = state.cryptoCurrencySymbol,
decimals = amount.decimals,
)
return state.copy(
return currentStateProvider().copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeCoverage(
amount = feeAmount,
onConfirmClick = clickIntents::onSubtractSelect,
),
),
@ -110,6 +106,20 @@ internal class SendEventStateFactory(
)
}
fun getFeeTooHighAlert(diff: String, onConsume: () -> Unit): SendUiState {
return currentStateProvider().copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeTooHigh(
onConfirmClick = clickIntents::showSend,
times = diff,
),
),
onConsume = onConsume,
),
)
}
fun getGenericErrorState(error: Throwable? = null, onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(
@ -123,4 +133,18 @@ internal class SendEventStateFactory(
),
)
}
fun getFeeUnreachableErrorState(onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeUnreachableError(
onConfirmClick = { clickIntents.feeReload(true) },
),
),
onConsume = onConsume,
),
)
}
}

View file

@ -4,11 +4,11 @@ import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.ValidateWalletMemoUseCase
import com.tangem.features.send.impl.R
@ -18,12 +18,14 @@ import com.tangem.features.send.impl.presentation.state.amount.SendAmountSubtrac
import com.tangem.features.send.impl.presentation.state.confirm.SendConfirmStateConverter
import com.tangem.features.send.impl.presentation.state.fee.SendFeeStateConverter
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.SendRecipientHistoryListConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientStateConverter
import com.tangem.features.send.impl.presentation.state.recipient.SendRecipientWalletListConverter
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.toPersistentList
import timber.log.Timber
@Suppress("LongParameterList")
@ -33,10 +35,9 @@ internal class SendStateFactory(
private val userWalletProvider: Provider<UserWallet>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val isTapHelpPreviewEnabledProvider: Provider<Boolean>,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
@ -79,9 +80,11 @@ internal class SendStateFactory(
isTapHelpPreviewEnabledProvider = isTapHelpPreviewEnabledProvider,
)
}
private val recipientListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientListConverter(
currentStateProvider = currentStateProvider,
private val recipientWalletListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientWalletListConverter()
}
private val recipientHistoryListStateConverter by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientHistoryListConverter(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
)
}
@ -92,7 +95,7 @@ internal class SendStateFactory(
event = consumedEvent(),
isEditingDisabled = false,
isBalanceHidden = false,
cryptoCurrencySymbol = "",
cryptoCurrencyName = "",
)
fun getReadyState(): SendUiState {
@ -103,7 +106,7 @@ internal class SendStateFactory(
?: recipientStateConverter.convert(SendRecipientStateConverter.Data("", null)),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
sendState = confirmStateConverter.convert(Unit),
cryptoCurrencySymbol = cryptoCurrencyStatusProvider().currency.symbol,
cryptoCurrencyName = cryptoCurrencyStatusProvider().currency.name,
)
}
@ -115,7 +118,7 @@ internal class SendStateFactory(
?: recipientStateConverter.convert(SendRecipientStateConverter.Data(destinationAddress, memo)),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
isEditingDisabled = true,
cryptoCurrencySymbol = cryptoCurrencyStatusProvider().currency.symbol,
cryptoCurrencyName = cryptoCurrencyStatusProvider().currency.name,
)
}
@ -125,11 +128,23 @@ internal class SendStateFactory(
//endregion
//region recipient
fun onLoadedRecipientList(wallets: List<AvailableWallet?>, txHistory: List<TxHistoryItem>): SendUiState =
recipientListStateConverter.convert(
wallets = wallets,
txHistory = txHistory,
fun onLoadedWalletsList(wallets: List<AvailableWallet?>): SendUiState {
val state = currentStateProvider()
return state.copy(
recipientState = state.recipientState?.copy(
wallets = recipientWalletListStateConverter.convert(wallets),
),
)
}
fun onLoadedHistoryList(txHistory: List<TxHistoryItem>): SendUiState {
val state = currentStateProvider()
return state.copy(
recipientState = state.recipientState?.copy(
recent = recipientHistoryListStateConverter.convert(txHistory),
),
)
}
fun onRecipientAddressValueChange(value: String, isXAddress: Boolean = false): SendUiState {
val state = currentStateProvider()
@ -230,6 +245,22 @@ internal class SendStateFactory(
),
)
}
fun getHiddenRecentListState(isAddressInWallet: Boolean, isValidAddress: Boolean): SendUiState {
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
val isNotValid = isAddressInWallet || !isValidAddress
return state.copy(
recipientState = recipientState.copy(
recent = recipientState.recent.map { recent ->
recent.copy(isVisible = isNotValid && (recent.isLoading || recent.title != TextReference.EMPTY))
}.toPersistentList(),
wallets = recipientState.wallets.map { wallet ->
wallet.copy(isVisible = isNotValid && (wallet.isLoading || wallet.title != TextReference.EMPTY))
}.toPersistentList(),
),
)
}
//endregion
//region send
@ -251,14 +282,9 @@ internal class SendStateFactory(
)
}
fun getTransactionSendState(txData: TransactionData): SendUiState {
fun getTransactionSendState(txData: TransactionData, txUrl: String): SendUiState {
val state = currentStateProvider()
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val sendState = state.sendState ?: return state
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txData.hash.orEmpty(),
networkId = cryptoCurrency.network.id,
).getOrElse { "" }
return state.copy(
sendState = sendState.copy(
transactionDate = txData.date?.timeInMillis ?: System.currentTimeMillis(),

View file

@ -1,10 +1,14 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class SendTransactionAlertConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val clickIntents: SendClickIntents,
) : Converter<SendTransactionError, SendAlertState?> {
override fun convert(value: SendTransactionError): SendAlertState? {
@ -29,8 +33,8 @@ internal class SendTransactionAlertConverter(
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.NetworkError -> SendAlertState.TransactionError(
code = "",
cause = value.message,
code = value.code.orEmpty(),
cause = value.message.orEmpty(),
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.UnknownError -> SendAlertState.TransactionError(
@ -38,7 +42,12 @@ internal class SendTransactionAlertConverter(
cause = value.ex?.localizedMessage,
onConfirmClick = { clickIntents.onFailedTxEmailClick(value.ex?.localizedMessage.orEmpty()) },
)
is SendTransactionError.CreateAccountUnderfunded -> SendAlertState.ReserveAmount(value.amount)
is SendTransactionError.CreateAccountUnderfunded -> SendAlertState.ReserveAmount(
BigDecimalFormatter.formatWithSymbol(
amount = value.amount,
symbol = cryptoCurrencyStatusProvider().currency.symbol,
),
)
else -> null
}
}

View file

@ -23,7 +23,7 @@ import java.math.BigDecimal
internal data class SendUiState(
val clickIntents: SendClickIntents,
val isEditingDisabled: Boolean,
val cryptoCurrencySymbol: String,
val cryptoCurrencyName: String,
val amountState: SendStates.AmountState? = null,
val recipientState: SendStates.RecipientState? = null,
val feeState: SendStates.FeeState? = null,
@ -52,6 +52,7 @@ internal sealed class SendStates {
val notifications: ImmutableList<SendNotification>,
val appCurrencyCode: String,
val isFeeLoading: Boolean,
val subtractedFee: BigDecimal?,
) : SendStates()
/** Recipient state */
@ -77,6 +78,7 @@ internal sealed class SendStates {
val rate: BigDecimal?,
val appCurrency: AppCurrency,
val isFeeApproximate: Boolean,
val isCustomSelected: Boolean,
val notifications: ImmutableList<SendNotification>,
) : SendStates()

View file

@ -1,38 +0,0 @@
package com.tangem.features.send.impl.presentation.state.amount
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.StateRouter
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
internal class AmountNotificationFactory(
private val stateRouterProvider: Provider<StateRouter>,
private val currentStateProvider: Provider<SendUiState>,
private val clickIntents: SendClickIntents,
) {
fun create() = stateRouterProvider().currentState
.filter { it.type == SendUiStateType.Amount }
.map {
buildList {
addFeeUnreachableNotification()
}.toImmutableList()
}
private fun MutableList<SendNotification>.addFeeUnreachableNotification() {
val state = currentStateProvider()
val feeState = state.feeState ?: return
if (feeState.feeSelectorState is FeeSelectorState.Error) {
add(
SendNotification.Warning.NetworkFeeUnreachable(clickIntents::feeReload),
)
}
}
}

View file

@ -41,6 +41,7 @@ internal class SendAmountStateConverter(
notifications = persistentListOf(),
isFeeLoading = false,
appCurrencyCode = appCurrency.code,
subtractedFee = null,
segmentedButtonConfig = if (status.value.fiatRate.isNullOrZero()) {
persistentListOf()
} else {

View file

@ -15,14 +15,16 @@ internal class SendAmountSubtractConverter(
val state = currentStateProvider()
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val amountState = state.amountState ?: return state
val feeValue = state.feeState?.fee?.amount?.value ?: return state
val feeState = state.feeState ?: return state
val feeValue = feeState.fee?.amount?.value ?: return state
val amountTextField = amountState.amountTextField
val amountValue = amountTextField.cryptoAmount.value ?: return state
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
val decimalCryptoValue = amountValue.minus(feeValue)
val feeDiff = amountState.subtractedFee?.let { feeValue.minus(it) } ?: feeValue
val decimalCryptoValue = amountValue.minus(feeDiff)
if (decimalCryptoValue < BigDecimal.ZERO) return state
@ -33,6 +35,7 @@ internal class SendAmountSubtractConverter(
return state.copy(
sendState = state.sendState?.copy(isSubtract = true),
amountState = amountState.copy(
subtractedFee = feeValue,
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,

View file

@ -4,6 +4,7 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.minimalAmount
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.utils.BigDecimalFormatter
@ -20,6 +21,7 @@ import com.tangem.features.send.impl.presentation.state.*
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.viewmodel.SendClickIntents
import com.tangem.lib.crypto.BlockchainUtils.isDogecoin
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.ImmutableList
@ -29,11 +31,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
import java.math.BigInteger
@Suppress("LongParameterList")
internal class SendNotificationFactory(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feePaidCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val currencyChecksRepository: CurrencyChecksRepository,
@ -51,18 +55,17 @@ internal class SendNotificationFactory(
val feeState = state.feeState ?: return@map persistentListOf()
val feeAmount = feeState.fee?.amount?.value ?: BigDecimal.ZERO
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
val sendAmount = if (sendState.isSubtract) amountValue.minus(feeAmount) else amountValue
buildList {
// errors
addExceedBalanceNotification(feeAmount, sendAmount)
addExceedBalanceNotification(feeAmount, amountValue)
addExceedsBalanceNotification(feeState.fee)
addInvalidAmountNotification(sendState.isSubtract, sendAmount)
addMinimumAmountErrorNotification(feeAmount, sendAmount)
addDustWarningNotification(feeAmount, sendAmount)
addTransactionLimitErrorNotification(feeAmount, sendAmount)
addMinimumAmountErrorNotification(feeAmount, amountValue)
addDustWarningNotification(feeAmount, amountValue)
addTransactionLimitErrorNotification(feeAmount, amountValue)
// warnings
addExistentialWarningNotification(feeAmount, sendAmount)
addHighFeeWarningNotification(sendAmount, sendState.ignoreAmountReduce)
addExistentialWarningNotification(feeAmount, amountValue)
addHighFeeWarningNotification(feeAmount, amountValue, sendState.ignoreAmountReduce)
addTooHighNotification(feeState.feeSelectorState)
addTooLowNotification(feeState)
}.toImmutableList()
}
@ -87,10 +90,11 @@ internal class SendNotificationFactory(
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val coinCryptoCurrencyStatus = coinCryptoCurrencyStatusProvider()
val feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatusProvider()
val cryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val coinCryptoAmount = coinCryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val showNotification = if (cryptoCurrencyStatus.currency is CryptoCurrency.Token) {
val showNotification = if (cryptoCurrencyStatus.currency.id == feePaidCryptoCurrencyStatus?.currency?.id) {
receivedAmount > cryptoAmount || feeAmount > coinCryptoAmount
} else {
receivedAmount + feeAmount > cryptoAmount
@ -101,15 +105,6 @@ internal class SendNotificationFactory(
}
}
private fun MutableList<SendNotification>.addInvalidAmountNotification(
isSubtractAmount: Boolean,
receivedAmount: BigDecimal,
) {
if (isSubtractAmount && receivedAmount <= BigDecimal.ZERO) {
add(SendNotification.Error.InvalidAmount)
}
}
private fun MutableList<SendNotification>.addMinimumAmountErrorNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
@ -119,20 +114,11 @@ internal class SendNotificationFactory(
val totalAmount = feeAmount + receivedAmount
val balance = coinCryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
// TODO Move Blockchain check elsewhere
when (coinCryptoCurrencyStatus.currency.network.id.value) {
Blockchain.Cardano.id -> {
if (receivedAmount > BigDecimal.ONE || balance - totalAmount < BigDecimal.ONE) {
add(SendNotification.Error.MinimumAmountError(CARDANO_MINIMUM))
}
if (isDogecoin(coinCryptoCurrencyStatus.currency.network.id.value)) {
val minimum = BigDecimal(DOGECOIN_MINIMUM)
if (receivedAmount < minimum || balance - totalAmount < minimum) {
add(SendNotification.Error.MinimumAmountError(DOGECOIN_MINIMUM))
}
Blockchain.Dogecoin.id -> {
val minimum = BigDecimal(DOGECOIN_MINIMUM)
if (receivedAmount > minimum || balance - totalAmount < minimum) {
add(SendNotification.Error.MinimumAmountError(DOGECOIN_MINIMUM))
}
}
else -> Unit
}
}
@ -181,9 +167,8 @@ internal class SendNotificationFactory(
cryptoCurrency = cryptoCurrency,
),
onConfirmClick = {
val reduceTo = utxoLimit.maxAmount.toPlainString()
clickIntents.onAmountReduceClick(
reduceTo,
utxoLimit.maxAmount,
SendNotification.Error.TransactionLimitError::class.java,
)
},
@ -197,7 +182,9 @@ internal class SendNotificationFactory(
receivedAmount: BigDecimal,
) {
val userWalletId = userWalletProvider().walletId
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val balance = cryptoCurrencyStatus.value.amount ?: return
val spendingAmount = if (cryptoCurrency is CryptoCurrency.Token) {
feeAmount
} else {
@ -207,7 +194,8 @@ internal class SendNotificationFactory(
userWalletId,
cryptoCurrency.network,
)
if (currencyDeposit != null && currencyDeposit > spendingAmount) {
val diff = balance.minus(spendingAmount)
if (currencyDeposit != null && currencyDeposit > diff) {
add(
SendNotification.Error.ExistentialDeposit(
BigDecimalFormatter.formatCryptoAmount(
@ -220,18 +208,21 @@ internal class SendNotificationFactory(
}
private fun MutableList<SendNotification>.addHighFeeWarningNotification(
feeAmount: BigDecimal,
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) {
val threshold = Blockchain.Tezos.minimalAmount()
val isTotalBalance = feeAmount.plus(sendAmount) >= balance && balance > threshold
if (!ignoreAmountReduce && isTotalBalance && isTezos) {
add(
SendNotification.Warning.HighFeeError(
amount = TEZOS_FEE_THRESHOLD.toPlainString(),
amount = threshold.toPlainString(),
onConfirmClick = {
val reduceTo = sendAmount.minus(TEZOS_FEE_THRESHOLD).toPlainString()
val reduceTo = sendAmount.minus(threshold)
clickIntents.onAmountReduceClick(reduceTo, SendNotification.Warning.HighFeeError::class.java)
},
onCloseClick = {
@ -281,6 +272,18 @@ internal class SendNotificationFactory(
}
}
private fun MutableList<SendNotification>.addTooHighNotification(feeSelectorState: FeeSelectorState) {
if (feeSelectorState !is FeeSelectorState.Content) return
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return
val highValue = multipleFees.priority.amount.value ?: return
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = (customValue / highValue).toBigInteger()
if (feeSelectorState.selectedFee == FeeType.Custom && diff > FEE_MAX_DIFF) {
add(SendNotification.Warning.TooHigh(diff.toString()))
}
}
private suspend fun MutableList<SendNotification>.addExceedsBalanceNotification(fee: Fee?) {
val feeValue = fee?.amount?.value ?: BigDecimal.ZERO
val userWalletId = userWalletProvider().walletId
@ -359,8 +362,7 @@ internal class SendNotificationFactory(
}
companion object {
private const val CARDANO_MINIMUM = "1"
private const val DOGECOIN_MINIMUM = "0.01"
private val TEZOS_FEE_THRESHOLD = BigDecimal("0.01")
internal val FEE_MAX_DIFF = BigInteger("5")
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.confirm.SendNotificationFactory
/**
* Check if sending amount with fee is greater than balance
@ -12,7 +13,7 @@ internal fun checkFeeCoverage(state: SendUiState, cryptoCurrencyStatus: CryptoCu
val balance = cryptoCurrencyStatus.value.amount ?: return false
val fee = state.feeState?.fee?.amount?.value ?: return false
val amount = state.amountState?.amountTextField?.cryptoAmount?.value ?: return false
return balance <= amount + fee
return balance < amount + fee && balance > fee
}
/**
@ -26,4 +27,19 @@ internal fun checkIfFeeTooLow(state: SendUiState): Boolean {
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
return feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue
}
/**
* Check if custom fee is too high
*/
internal fun checkIfFeeTooHigh(state: SendUiState, onShow: (String) -> Unit): Boolean {
val feeSelectorState = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return false
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return false
val highValue = multipleFees.priority.amount.value ?: return false
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return false
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = (customValue / highValue).toBigInteger()
val isShow = feeSelectorState.selectedFee == FeeType.Custom && diff > SendNotificationFactory.FEE_MAX_DIFF
if (isShow) onShow(diff.toString())
return isShow
}

View file

@ -5,6 +5,7 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.fee.custom.BitcoinCustomFeeConverter
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
@ -13,10 +14,10 @@ import com.tangem.utils.converter.Converter
internal class FeeConverter(
private val clickIntents: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : Converter<FeeSelectorState.Content, Fee> {
private val ethereumCustomFeeConverter by lazy {
private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
EthereumCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
@ -24,6 +25,14 @@ internal class FeeConverter(
)
}
private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
BitcoinCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
override fun convert(value: FeeSelectorState.Content): Fee {
return when (val fees = value.fees) {
is TransactionFee.Choosable -> {
@ -46,6 +55,7 @@ internal class FeeConverter(
} else {
when (normalFee) {
is Fee.Ethereum -> ethereumCustomFeeConverter.convertBack(normalFee = normalFee, value = customValues)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.convertBack(normalFee = normalFee, value = customValues)
else -> {
val customFee = customValues.firstOrNull()
Fee.Common(

View file

@ -1,20 +1,15 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.features.send.impl.presentation.state.SendNotification
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.StateRouter
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.toFormattedString
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class FeeNotificationFactory(
@ -29,16 +24,7 @@ internal class FeeNotificationFactory(
val state = currentStateProvider()
val feeState = state.feeState ?: return@map persistentListOf()
buildList {
when (val feeSelectorState = feeState.feeSelectorState) {
FeeSelectorState.Error -> {
addFeeUnreachableNotification(feeSelectorState)
}
is FeeSelectorState.Content -> {
val customFee = feeSelectorState.customValues
val selectedFee = feeSelectorState.selectedFee
addTooHighNotification(feeSelectorState.fees, selectedFee, customFee)
}
}
addFeeUnreachableNotification(feeState.feeSelectorState)
}.toImmutableList()
}
@ -47,24 +33,4 @@ internal class FeeNotificationFactory(
add(SendNotification.Warning.NetworkFeeUnreachable(clickIntents::feeReload))
}
}
private fun MutableList<SendNotification>.addTooHighNotification(
transactionFee: TransactionFee,
selectedFee: FeeType,
customFee: List<SendTextField.CustomFee>,
) {
val multipleFees = transactionFee as? TransactionFee.Choosable ?: return
val highValue = multipleFees.priority.amount.value ?: return
val customAmount = customFee.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = customValue / highValue
if (selectedFee == FeeType.Custom && diff > FEE_MAX_DIFF) {
add(SendNotification.Warning.TooHigh(diff.toFormattedString(HIGH_FEE_DIFF_DECIMALS)))
}
}
companion object {
private val FEE_MAX_DIFF = BigDecimal(5)
private const val HIGH_FEE_DIFF_DECIMALS = 0
}
}

View file

@ -21,7 +21,7 @@ import kotlinx.collections.immutable.persistentListOf
internal class FeeStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
) {
@ -56,18 +56,26 @@ internal class FeeStateFactory(
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = (feeState.feeSelectorState as? FeeSelectorState.Content)?.copy(
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content
val isCustomWasSelected = if (feeState.isCustomSelected) {
feeSelectorState?.customValues ?: persistentListOf()
} else {
customFeeFieldConverter.convert(fees.normal)
}
val updatedFeeSelectorState = feeSelectorState?.copy(
fees = fees,
customValues = isCustomWasSelected,
) ?: FeeSelectorState.Content(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = feeConverter.convert(feeSelectorState)
val fee = feeConverter.convert(updatedFeeSelectorState)
return state.copy(
amountState = state.amountState?.copy(isFeeLoading = false),
feeState = feeState.copy(
feeSelectorState = feeSelectorState,
feeSelectorState = updatedFeeSelectorState,
fee = fee,
isFeeApproximate = isFeeApproximate(fee),
),
@ -91,9 +99,11 @@ internal class FeeStateFactory(
val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType)
val fee = feeConverter.convert(updatedFeeSelectorState)
val isCustomFeeWasSelected = feeState.isCustomSelected || updatedFeeSelectorState.selectedFee == FeeType.Custom
return state.copy(
feeState = feeState.copy(
fee = fee,
isCustomSelected = isCustomFeeWasSelected,
feeSelectorState = updatedFeeSelectorState,
),
)
@ -143,7 +153,7 @@ internal class FeeStateFactory(
}
private fun isFeeApproximate(fee: Fee): Boolean {
val cryptoCurrencyStatus = feeCryptoCurrencyStatusProvider()
val cryptoCurrencyStatus = feeCryptoCurrencyStatusProvider() ?: return false
return isFeeApproximateUseCase(
networkId = cryptoCurrencyStatus.currency.network.id,
amountType = fee.amount.type,

View file

@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.fee.custom.BitcoinCustomFeeConverter
import com.tangem.features.send.impl.presentation.state.fee.custom.EthereumCustomFeeConverter
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
@ -14,10 +15,10 @@ import kotlinx.collections.immutable.persistentListOf
internal class SendFeeCustomFieldConverter(
private val clickIntents: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : Converter<Fee, ImmutableList<SendTextField.CustomFee>> {
private val ethereumCustomFeeConverter by lazy {
private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
EthereumCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
@ -25,22 +26,35 @@ internal class SendFeeCustomFieldConverter(
)
}
private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
BitcoinCustomFeeConverter(
clickIntents = clickIntents,
appCurrencyProvider = appCurrencyProvider,
feeCryptoCurrencyStatusProvider = feeCryptoCurrencyStatusProvider,
)
}
override fun convert(value: Fee): ImmutableList<SendTextField.CustomFee> {
return when (value) {
is Fee.Ethereum -> {
ethereumCustomFeeConverter.convert(value)
}
is Fee.Ethereum -> ethereumCustomFeeConverter.convert(value)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.convert(value)
else -> persistentListOf()
}
}
fun onValueChange(feeSelectorState: FeeSelectorState.Content, index: Int, value: String) = feeSelectorState.copy(
customValues = when (feeSelectorState.fees.normal) {
customValues = when (val fee = feeSelectorState.fees.normal) {
is Fee.Ethereum -> ethereumCustomFeeConverter.onValueChange(
customValues = feeSelectorState.customValues,
index = index,
value = value,
)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.onValueChange(
customValues = feeSelectorState.customValues,
index = index,
value = value,
txSize = fee.txSize,
)
else -> feeSelectorState.customValues
},
)

View file

@ -9,7 +9,7 @@ import kotlinx.collections.immutable.persistentListOf
internal class SendFeeStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : Converter<Unit, SendStates.FeeState> {
override fun convert(value: Unit): SendStates.FeeState {
@ -17,9 +17,10 @@ internal class SendFeeStateConverter(
feeSelectorState = FeeSelectorState.Error,
fee = null,
notifications = persistentListOf(),
rate = feeCryptoCurrencyStatusProvider().value.fiatRate,
rate = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,
appCurrency = appCurrencyProvider(),
isFeeApproximate = false,
isCustomSelected = false,
)
}
}

View file

@ -0,0 +1,145 @@
package com.tangem.features.send.impl.presentation.state.fee.custom
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.extensions.isZero
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.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
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.lib.crypto.BlockchainUtils.isBitcoin
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
internal class BitcoinCustomFeeConverter(
private val clickIntents: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : CustomFeeConverter<Fee.Bitcoin> {
override fun convert(value: Fee.Bitcoin): ImmutableList<SendTextField.CustomFee> {
val feeValue = value.amount.value
val network = feeCryptoCurrencyStatusProvider()?.currency?.network?.id?.value
return if (network != null && isBitcoin(network)) {
persistentListOf(
SendTextField.CustomFee(
value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(),
decimals = value.amount.decimals,
symbol = value.amount.currencySymbol,
onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT_INDEX, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number,
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer),
label = getFeeFormatted(feeValue),
keyboardActions = KeyboardActions(),
isReadonly = true,
),
SendTextField.CustomFee(
value = toSatoshiPerByte(
amount = feeValue,
decimals = value.amount.decimals,
txSize = value.txSize,
).toString(),
decimals = SATOSHI_DECIMALS,
symbol = "",
title = resourceReference(R.string.send_satoshi_per_byte_title),
footer = resourceReference(R.string.send_satoshi_per_byte_text),
onValueChange = { clickIntents.onCustomFeeValueChange(FEE_SATOSHI_INDEX, it) },
keyboardOptions = KeyboardOptions(
imeAction = if (checkExceedBalance(feeValue)) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
),
)
} else {
persistentListOf()
}
}
override fun convertBack(normalFee: Fee.Bitcoin, value: ImmutableList<SendTextField.CustomFee>): Fee.Bitcoin {
val feeAmount = value[FEE_AMOUNT_INDEX].value.parseToBigDecimal(value[FEE_AMOUNT_INDEX].decimals)
val satoshiPerByte = value[FEE_SATOSHI_INDEX].value.parseToBigDecimal(value[FEE_SATOSHI_INDEX].decimals)
return normalFee.copy(
amount = normalFee.amount.copy(value = feeAmount),
satoshiPerByte = satoshiPerByte,
)
}
fun onValueChange(
customValues: ImmutableList<SendTextField.CustomFee>,
index: Int,
value: String,
txSize: BigDecimal,
): ImmutableList<SendTextField.CustomFee> {
val mutableCustomValues = customValues.toMutableList()
return mutableCustomValues.apply {
if (index == FEE_SATOSHI_INDEX) {
val newSatoshiPerKb = value.parseToBigDecimal(this[FEE_SATOSHI_INDEX].decimals)
val newFeeAmount = newSatoshiPerKb.multiply(txSize)
.movePointLeft(this[FEE_AMOUNT_INDEX].decimals)
.setScale(this[FEE_AMOUNT_INDEX].decimals, RoundingMode.DOWN)
set(
FEE_AMOUNT_INDEX,
this[FEE_AMOUNT_INDEX].copy(
value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT_INDEX].decimals),
label = getFeeFormatted(newFeeAmount),
),
)
set(index, this[index].copy(value = value))
}
}.toImmutableList()
}
private fun getFeeFormatted(fee: BigDecimal?): TextReference {
val appCurrency = appCurrencyProvider()
val rate = feeCryptoCurrencyStatusProvider()?.value?.fiatRate
val fiatFee = rate?.let { fee?.multiply(it) }
return stringReference(
BigDecimalFormatter.formatFiatAmount(
fiatAmount = fiatFee,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
),
)
}
private fun checkExceedBalance(feeAmount: BigDecimal?): Boolean {
val cryptoCurrencyStatus = feeCryptoCurrencyStatusProvider()
val currencyCryptoAmount = cryptoCurrencyStatus?.value?.amount ?: BigDecimal.ZERO
return feeAmount == null || feeAmount.isZero() || feeAmount > currencyCryptoAmount
}
private fun toSatoshiPerByte(amount: BigDecimal?, decimals: Int, txSize: BigDecimal): BigDecimal? {
val newFeeAmount = amount?.movePointRight(decimals)
return newFeeAmount?.divide(
txSize,
SATOSHI_DECIMALS,
RoundingMode.HALF_UP,
)?.setScale(SATOSHI_DECIMALS, RoundingMode.HALF_UP)
}
private companion object {
private const val FEE_AMOUNT_INDEX = 0
private const val FEE_SATOSHI_INDEX = 1
private const val SATOSHI_DECIMALS = 0
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.send.impl.presentation.state.fee.custom
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
internal interface CustomFeeConverter<T : Fee> : Converter<T, ImmutableList<SendTextField.CustomFee>> {
fun convertBack(normalFee: T, value: ImmutableList<SendTextField.CustomFee>): T
}

View file

@ -18,7 +18,6 @@ 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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -28,8 +27,8 @@ import java.math.RoundingMode
internal class EthereumCustomFeeConverter(
private val clickIntents: SendClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : Converter<Fee.Ethereum, ImmutableList<SendTextField.CustomFee>> {
private val feeCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
) : CustomFeeConverter<Fee.Ethereum> {
override fun convert(value: Fee.Ethereum): ImmutableList<SendTextField.CustomFee> {
val feeValue = value.amount.value
@ -49,8 +48,8 @@ internal class EthereumCustomFeeConverter(
keyboardActions = KeyboardActions(),
),
SendTextField.CustomFee(
value = value.gasPrice.toString(),
decimals = GAS_DECIMALS,
value = value.gasPrice.toBigDecimal().movePointLeft(GIGA_DECIMALS).parseBigDecimal(GIGA_DECIMALS),
decimals = GIGA_DECIMALS,
symbol = ETHEREUM_GAS_UNIT,
title = resourceReference(R.string.send_gas_price),
footer = resourceReference(R.string.send_gas_price_footer),
@ -77,7 +76,7 @@ internal class EthereumCustomFeeConverter(
)
}
fun convertBack(normalFee: Fee.Ethereum, value: ImmutableList<SendTextField.CustomFee>): Fee.Ethereum {
override fun convertBack(normalFee: Fee.Ethereum, value: ImmutableList<SendTextField.CustomFee>): Fee.Ethereum {
val feeAmount = value[FEE_AMOUNT].value.parseToBigDecimal(value[FEE_AMOUNT].decimals)
val gasPrice = value[GAS_PRICE].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger()
val gasLimit = value[GAS_LIMIT].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger()
@ -105,7 +104,7 @@ internal class EthereumCustomFeeConverter(
private fun getFeeFormatted(fee: BigDecimal?): TextReference {
val appCurrency = appCurrencyProvider()
val rate = feeCryptoCurrencyStatusProvider().value.fiatRate
val rate = feeCryptoCurrencyStatusProvider()?.value?.fiatRate
val fiatFee = rate?.let { fee?.multiply(it) }
return stringReference(
BigDecimalFormatter.formatFiatAmount(
@ -118,7 +117,7 @@ internal class EthereumCustomFeeConverter(
private fun checkExceedBalance(feeAmount: BigDecimal?): Boolean {
val cryptoCurrencyStatus = feeCryptoCurrencyStatusProvider()
val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val currencyCryptoAmount = cryptoCurrencyStatus?.value?.amount ?: BigDecimal.ZERO
return feeAmount == null || feeAmount.isZero() || feeAmount > currencyCryptoAmount
}
@ -134,9 +133,9 @@ internal class EthereumCustomFeeConverter(
setEmpty(GAS_PRICE)
} else {
val newFeeAmountDecimal = value.parseToBigDecimal(this[FEE_AMOUNT].decimals)
val newFeeAmount = newFeeAmountDecimal.movePointRight(this[FEE_AMOUNT].decimals)
val newGasPrice = newFeeAmount.divide(gasLimit, GAS_DECIMALS, RoundingMode.HALF_UP)
set(GAS_PRICE, this[GAS_PRICE].copy(value = newGasPrice.parseBigDecimal(GAS_DECIMALS)))
val newFeeAmount = newFeeAmountDecimal.movePointRight(this[GAS_PRICE].decimals) // from ETH to GWEI
val newGasPrice = newFeeAmount.divide(gasLimit, this[GAS_PRICE].decimals, RoundingMode.HALF_UP)
set(GAS_PRICE, this[GAS_PRICE].copy(value = newGasPrice.parseBigDecimal(this[GAS_PRICE].decimals)))
set(
index,
this[index].copy(
@ -154,8 +153,8 @@ internal class EthereumCustomFeeConverter(
setEmpty(GAS_PRICE)
} else {
val newGasPrice = value.parseToBigDecimal(this[GAS_PRICE].decimals)
.movePointLeft(this[GAS_PRICE].decimals)
val newFeeAmount = (gasLimit * newGasPrice).movePointLeft(this[FEE_AMOUNT].decimals)
.movePointLeft(this[GAS_PRICE].decimals) // from GWEI to ETH
val newFeeAmount = gasLimit * newGasPrice
set(
FEE_AMOUNT,
this[FEE_AMOUNT].copy(
@ -174,7 +173,7 @@ internal class EthereumCustomFeeConverter(
} else {
val newGasLimit = value.parseToBigDecimal(this[GAS_LIMIT].decimals)
val gasPrice = this[GAS_PRICE].value.parseToBigDecimal(this[GAS_PRICE].decimals)
.movePointLeft(this[FEE_AMOUNT].decimals)
.movePointLeft(this[GAS_PRICE].decimals) // from GWEI to ETH
val newFeeAmount = newGasLimit * gasPrice
set(
FEE_AMOUNT,
@ -198,6 +197,7 @@ internal class EthereumCustomFeeConverter(
companion object {
private const val ETHEREUM_GAS_UNIT = "GWEI"
private const val GIGA_DECIMALS = 9
private const val FEE_AMOUNT = 0
private const val GAS_PRICE = 1
private const val GAS_LIMIT = 2

View file

@ -38,6 +38,7 @@ internal class SendAmountFieldChangeConverter(
return state.copy(
amountState = amountState.copy(
isPrimaryButtonEnabled = !isExceedBalance && !isZero,
subtractedFee = null,
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,
@ -45,7 +46,7 @@ internal class SendAmountFieldChangeConverter(
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
keyboardOptions = KeyboardOptions(
imeAction = if (!isExceedBalance) ImeAction.Done else ImeAction.None,
imeAction = getKeyboardAction(isExceedBalance, decimalCryptoValue),
keyboardType = KeyboardType.Number,
),
),
@ -103,4 +104,11 @@ internal class SendAmountFieldChangeConverter(
cryptoDecimal > currencyCryptoAmount
}
}
private fun getKeyboardAction(isExceedBalance: Boolean, decimalCryptoValue: BigDecimal) =
if (!isExceedBalance && !decimalCryptoValue.isZero()) {
ImeAction.Done
} else {
ImeAction.None
}
}

View file

@ -34,6 +34,7 @@ internal class SendAmountFieldMaxAmountConverter(
return state.copy(
amountState = amountState.copy(
isPrimaryButtonEnabled = true,
subtractedFee = null,
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,

View file

@ -64,5 +64,6 @@ internal sealed class SendTextField {
val title: TextReference,
val footer: TextReference,
val label: TextReference? = null,
val isReadonly: Boolean = false,
) : SendTextField()
}

View file

@ -25,6 +25,7 @@ internal object AmountStatePreviewData {
notifications = persistentListOf(),
isFeeLoading = false,
appCurrencyCode = "usd",
subtractedFee = null,
amountTextField = SendTextField.AmountField(
value = "123.123123123123123123",
onValueChange = {},

View file

@ -48,6 +48,7 @@ internal object FeeStatePreviewData {
),
isFeeApproximate = false,
notifications = persistentListOf(),
isCustomSelected = false,
)
val errorFeeState = feeState.copy(

View file

@ -6,6 +6,7 @@ import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import java.math.BigDecimal
@Suppress("TooManyFunctions")
internal object SendClickIntentsStub : SendClickIntents {
@ -33,7 +34,7 @@ internal object SendClickIntentsStub : SendClickIntents {
override fun onRecipientMemoValueChange(value: String) {}
override fun feeReload() {}
override fun feeReload(isToNextState: Boolean) {}
override fun onFeeSelectorClick(feeType: FeeType) {}
@ -57,7 +58,7 @@ internal object SendClickIntentsStub : SendClickIntents {
override fun onShareClick() {}
override fun onAmountReduceClick(reducedAmount: String, clazz: Class<out SendNotification>) {}
override fun onAmountReduceClick(reducedAmount: BigDecimal, clazz: Class<out SendNotification>) {}
override fun onNotificationCancel(clazz: Class<out SendNotification>) {}
}

View file

@ -10,49 +10,26 @@ import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.recipient.utils.RECENT_DEFAULT_COUNT
import com.tangem.features.send.impl.presentation.state.recipient.utils.RECENT_KEY_TAG
import com.tangem.features.send.impl.presentation.state.recipient.utils.emptyListState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.toFormattedCurrencyString
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
internal class SendRecipientListConverter(
private val currentStateProvider: Provider<SendUiState>,
internal class SendRecipientHistoryListConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
) : Converter<List<TxHistoryItem>, ImmutableList<SendRecipientListContent>> {
fun convert(wallets: List<AvailableWallet?>, txHistory: List<TxHistoryItem>): SendUiState {
override fun convert(value: List<TxHistoryItem>): ImmutableList<SendRecipientListContent> {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
return state.copy(
recipientState = recipientState.copy(
wallets = wallets.filterWallets(),
recent = txHistory.filterRecipients(cryptoCurrency),
),
)
}
private fun List<AvailableWallet?>.filterWallets() = this.filterNotNull()
.groupBy { item -> item.name }
.values.map {
it.mapIndexed { index, item ->
val name = if (it.size > 1) {
"${item.name} ${index.inc()}"
} else {
item.name
}
SendRecipientListContent(
id = item.address,
title = TextReference.Str(item.address),
subtitle = TextReference.Str(name),
)
}
return value.filterRecipients(cryptoCurrency).ifEmpty {
emptyListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT)
}
.flatten()
.toPersistentList()
}
private fun List<TxHistoryItem>.filterRecipients(cryptoCurrency: CryptoCurrency) = this.filter { item ->
val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer
@ -65,9 +42,9 @@ internal class SendRecipientListConverter(
isTransfer && isSingleAddress && isNotContract
}
.take(RECENT_LIST_SIZE)
.map { tx ->
.mapIndexed { index, tx ->
SendRecipientListContent(
id = tx.txHash,
id = "$RECENT_KEY_TAG$index",
title = tx.extractAddress(),
subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()),
timestamp = tx.extractTimestamp(),

View file

@ -2,10 +2,10 @@ package com.tangem.features.send.impl.presentation.state.recipient
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.recipient.utils.*
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
internal class SendRecipientStateConverter(
private val clickIntents: SendClickIntents,
@ -26,8 +26,8 @@ internal class SendRecipientStateConverter(
memoTextField = memoFieldConverter.convertOrNull(value.memo),
network = cryptoCurrencyStatusProvider().currency.network.name,
isPrimaryButtonEnabled = false,
wallets = persistentListOf(),
recent = persistentListOf(),
wallets = loadingListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT),
recent = loadingListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT),
)
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.send.impl.presentation.state.recipient
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import com.tangem.features.send.impl.presentation.state.recipient.utils.WALLET_DEFAULT_COUNT
import com.tangem.features.send.impl.presentation.state.recipient.utils.WALLET_KEY_TAG
import com.tangem.features.send.impl.presentation.state.recipient.utils.emptyListState
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal class SendRecipientWalletListConverter :
Converter<List<AvailableWallet?>, PersistentList<SendRecipientListContent>> {
override fun convert(value: List<AvailableWallet?>): PersistentList<SendRecipientListContent> {
return value.filterWallets().ifEmpty {
emptyListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT)
}
}
private fun List<AvailableWallet?>.filterWallets() = this.filterNotNull()
.groupBy { item -> item.name }
.values.map {
it.mapIndexed { index, item ->
val name = if (it.size > 1) {
"${item.name} ${index.inc()}"
} else {
item.name
}
SendRecipientListContent(
id = "${WALLET_KEY_TAG}$index",
title = TextReference.Str(item.address),
subtitle = TextReference.Str(name),
)
}
}
.flatten()
.toPersistentList()
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.send.impl.presentation.state.recipient.utils
import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent
import kotlinx.collections.immutable.toPersistentList
internal const val WALLET_DEFAULT_COUNT = 1
internal const val RECENT_DEFAULT_COUNT = 3
internal const val WALLET_KEY_TAG = "wallet"
internal const val RECENT_KEY_TAG = "recent"
internal fun loadingListState(tag: String, count: Int) = buildList {
repeat(count) {
add(
SendRecipientListContent(
id = "$tag$it",
isLoading = true,
),
)
}
}.toPersistentList()
internal fun emptyListState(tag: String, count: Int) = buildList {
repeat(count) {
add(
SendRecipientListContent(
id = "$tag$it",
isLoading = false,
isVisible = false,
),
)
}
}.toPersistentList()

View file

@ -3,6 +3,7 @@ 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.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButton
@ -18,6 +19,11 @@ internal fun SendEventEffect(event: StateEvent<SendEvent>, snackbarHostState: Sn
val resources = LocalContext.current.resources
var alertConfig by remember { mutableStateOf<SendAlertState?>(value = null) }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(key1 = alertConfig) {
keyboardController?.hide()
}
alertConfig?.let {
SendAlert(state = it, onDismiss = { alertConfig = null })
}

View file

@ -140,7 +140,7 @@ private fun SendingText(uiState: SendUiState, isVisible: Boolean, modifier: Modi
val feeState = uiState.feeState
val fiatRate = feeState?.rate
val fiatAmount = amountState?.amountTextField?.fiatAmount
val feeFiat = feeState?.fee?.amount?.value?.multiply(fiatRate)
val feeFiat = fiatRate?.let { feeState.fee?.amount?.value?.multiply(it) }
val sendingFiat = feeFiat?.let { fiatAmount?.value?.plus(it) }
if (feeFiat != null && sendingFiat != null) {
@ -185,7 +185,7 @@ private fun SendDoneButtons(
exit = slideOutVertically().plus(fadeOut()),
label = "Animate show sent state buttons",
) {
Row(modifier = Modifier.padding(TangemTheme.dimens.spacing12)) {
Row(modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12)) {
SecondaryButtonIconStart(
text = stringResource(id = R.string.common_explore),
iconResId = R.drawable.ic_web_24,

View file

@ -11,7 +11,6 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
@ -25,40 +24,38 @@ import com.tangem.features.send.impl.presentation.ui.amount.SendAmountContent
import com.tangem.features.send.impl.presentation.ui.fee.SendSpeedAndFeeContent
import com.tangem.features.send.impl.presentation.ui.recipient.SendRecipientContent
import com.tangem.features.send.impl.presentation.ui.send.SendContent
import kotlinx.coroutines.flow.StateFlow
@Composable
internal fun SendScreen(uiState: SendUiState, currentStateFlow: StateFlow<SendUiCurrentScreen>) {
val currentState = currentStateFlow.collectAsStateWithLifecycle()
internal fun SendScreen(uiState: SendUiState, currentState: SendUiCurrentScreen) {
val snackbarHostState = remember { SnackbarHostState() }
val sendState = uiState.sendState ?: return
BackHandler { uiState.clickIntents.onBackClick() }
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
.systemBarsPadding()
.background(color = TangemTheme.colors.background.tertiary),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val titleRes = when (currentState.value.type) {
val titleRes = when (currentState.type) {
SendUiStateType.Amount -> resourceReference(R.string.send_amount_label)
SendUiStateType.Recipient -> resourceReference(R.string.send_recipient_label)
SendUiStateType.Fee -> resourceReference(R.string.common_fee_selector_title)
SendUiStateType.Send -> if (!sendState.isSuccess) {
resourceReference(R.string.send_summary_title, wrappedList(uiState.cryptoCurrencySymbol))
resourceReference(R.string.send_summary_title, wrappedList(uiState.cryptoCurrencyName))
} else {
null
}
else -> null
}
val isSending = currentState.value.type == SendUiStateType.Send && !uiState.sendState.isSuccess
val isSending = currentState.type == SendUiStateType.Send && !uiState.sendState.isSuccess
val subtitleRes = if (isSending) {
uiState.amountState?.walletName
} else {
null
}
val iconRes = if (currentState.value.type == SendUiStateType.Recipient) {
val iconRes = if (currentState.type == SendUiStateType.Recipient) {
R.drawable.ic_qrcode_scan_24
} else {
null
@ -74,17 +71,15 @@ internal fun SendScreen(uiState: SendUiState, currentStateFlow: StateFlow<SendUi
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
Box(modifier = Modifier.weight(1f)) {
SendScreenContent(
uiState = uiState,
currentState = currentState.value,
)
SendNavigationButtons(
uiState = uiState,
currentState = currentState.value,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
SendScreenContent(
uiState = uiState,
currentState = currentState,
modifier = Modifier.weight(1f),
)
SendNavigationButtons(
uiState = uiState,
currentState = currentState,
)
}
SendEventEffect(
@ -103,32 +98,36 @@ private fun SendScreenContent(uiState: SendUiState, currentState: SendUiCurrentS
AnimatedContentTransitionScope.SlideDirection.End
}
}
AnimatedContent(
targetState = currentState,
label = "Send Scree Navigation",
modifier = modifier,
transitionSpec = {
lastState = currentState.type.ordinal
slideIntoContainer(towards = direction, animationSpec = tween())
.togetherWith(slideOutOfContainer(towards = direction, animationSpec = tween()))
},
) { state ->
when (state.type) {
SendUiStateType.Amount -> SendAmountContent(
amountState = uiState.amountState,
isBalanceHiding = uiState.isBalanceHidden,
clickIntents = uiState.clickIntents,
)
SendUiStateType.Recipient -> SendRecipientContent(
uiState = uiState.recipientState,
clickIntents = uiState.clickIntents,
)
SendUiStateType.Fee -> SendSpeedAndFeeContent(
state = uiState.feeState,
clickIntents = uiState.clickIntents,
)
SendUiStateType.Send -> SendContent(uiState)
else -> Unit
// Box is needed to fix animation with resizing of AnimatedContent
Box(modifier = modifier) {
AnimatedContent(
targetState = currentState,
label = "Send Scree Navigation",
transitionSpec = {
lastState = currentState.type.ordinal
slideIntoContainer(towards = direction, animationSpec = tween())
.togetherWith(slideOutOfContainer(towards = direction, animationSpec = tween()))
},
) { state ->
when (state.type) {
SendUiStateType.Amount -> SendAmountContent(
amountState = uiState.amountState,
isBalanceHiding = uiState.isBalanceHidden,
clickIntents = uiState.clickIntents,
)
SendUiStateType.Recipient -> SendRecipientContent(
uiState = uiState.recipientState,
clickIntents = uiState.clickIntents,
isBalanceHidden = uiState.isBalanceHidden,
)
SendUiStateType.Fee -> SendSpeedAndFeeContent(
state = uiState.feeState,
clickIntents = uiState.clickIntents,
)
SendUiStateType.Send -> SendContent(uiState)
else -> Unit
}
}
}
}

View file

@ -7,9 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment.Companion.BottomCenter
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -24,6 +22,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.rememberDecimalFormat
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import kotlinx.coroutines.delay
import kotlinx.coroutines.job
@Composable
@ -37,6 +36,16 @@ internal fun AmountField(sendField: SendTextField.AmountField, isEnabled: Boolea
sendField.cryptoAmount to sendField.value
}
val requester = remember { FocusRequester() }
var isEnabledProxy by remember { mutableStateOf(isEnabled) }
// Fix animation from amount screen to summary screen ([REDACTED_TASK_KEY])
LaunchedEffect(key1 = isEnabled) {
if (isEnabled) {
delay(timeMillis = 700)
}
isEnabledProxy = isEnabled
}
AmountTextField(
value = primaryValue,
decimals = primaryAmount.decimals,
@ -53,7 +62,7 @@ internal fun AmountField(sendField: SendTextField.AmountField, isEnabled: Boolea
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
isEnabled = isEnabled,
isEnabled = isEnabledProxy,
isAutoResize = true,
modifier = Modifier
.focusRequester(requester)
@ -64,6 +73,7 @@ internal fun AmountField(sendField: SendTextField.AmountField, isEnabled: Boolea
)
.requiredHeightIn(min = TangemTheme.dimens.size32),
)
LaunchedEffect(key1 = Unit) {
this.coroutineContext.job.invokeOnCompletion {
requester.requestFocus()

View file

@ -24,7 +24,11 @@ internal fun SendAmountContent(
if (amountState == null) return
LazyColumn(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
)
.background(TangemTheme.colors.background.tertiary),
) {
amountField(amountState = amountState, isBalanceHiding = isBalanceHiding)

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