Updated on 2026-08-14

This commit is contained in:
Tangem 2024-02-02 16:12:15 +03:00
commit 36b7566a2a
184 changed files with 4396 additions and 1625 deletions

View file

@ -52,6 +52,7 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.transaction)
implementation(projects.domain.analytics)
implementation(projects.domain.visa)
implementation(projects.common)
implementation(projects.core.analytics)
@ -62,6 +63,7 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.deepLinks)
implementation(projects.libs.crypto)
implementation(projects.libs.auth)
@ -77,6 +79,7 @@ dependencies {
implementation(projects.data.wallets)
implementation(projects.data.analytics)
implementation(projects.data.transaction)
implementation(projects.data.visa)
/** Features */
implementation(projects.features.onboarding)

View file

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

View file

@ -55,6 +55,7 @@ class DialogManager : StoreSubscriber<GlobalState> {
)
is OnboardingDialog.TwinningProcessNotCompleted -> TwinningProcessNotCompletedDialog.create(context)
is OnboardingDialog.InterruptOnboarding -> InterruptOnboardingDialog.create(context, state.dialog)
is OnboardingDialog.WalletActivationError -> WalletActivationErrorDialog.create(context, state.dialog)
is WalletConnectDialog.UnsupportedCard ->
SimpleAlertDialog.create(
titleRes = R.string.wallet_connect_title,

View file

@ -46,6 +46,8 @@ sealed class GlobalAction : Action {
data class StartForUnfinishedBackup(val addedBackupCardsCount: Int) : Onboarding()
object Stop : Onboarding()
data class ShouldResetCardOnCreate(val shouldReset: Boolean) : Onboarding()
}
object ScanFailsCounter {

View file

@ -30,6 +30,11 @@ fun globalReducer(action: Action, state: AppState, appStateHolder: AppStateHolde
is GlobalAction.Onboarding.Stop -> {
globalState.copy(onboardingState = OnboardingState(false))
}
is GlobalAction.Onboarding.ShouldResetCardOnCreate -> {
globalState.copy(
onboardingState = globalState.onboardingState.copy(shouldResetOnCreate = action.shouldReset),
)
}
is GlobalAction.ScanFailsCounter.Increment -> {
globalState.copy(scanCardFailsCounter = globalState.scanCardFailsCounter + 1)
}

View file

@ -36,4 +36,5 @@ typealias CryptoCurrencyName = String
data class OnboardingState(
val onboardingStarted: Boolean = false,
val onboardingManager: OnboardingManager? = null,
val shouldResetOnCreate: Boolean = false,
)

View file

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

View file

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

View file

@ -2,12 +2,13 @@ package com.tangem.tap.di.domain
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.transaction.usecase.CreateTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -20,24 +21,21 @@ internal object TransactionDomainModule {
@Provides
@ViewModelScoped
fun provideGetFeeUseCase(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade, dispatchers)
fun provideGetFeeUseCase(walletManagersFacade: WalletManagersFacade): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade)
}
@Provides
@ViewModelScoped
fun provideSendTransactionUseCase(
isDemoCardUseCase: IsDemoCardUseCase,
walletManagersFacade: WalletManagersFacade,
cardSdkConfigRepository: CardSdkConfigRepository,
transactionRepository: TransactionRepository,
): SendTransactionUseCase {
return SendTransactionUseCase(
isDemoCardUseCase = isDemoCardUseCase,
cardSdkConfigRepository = cardSdkConfigRepository,
walletManagersFacade = walletManagersFacade,
transactionRepository = transactionRepository,
)
}
@ -46,4 +44,10 @@ internal object TransactionDomainModule {
fun provideCreateTransactionUseCase(transactionRepository: TransactionRepository): CreateTransactionUseCase {
return CreateTransactionUseCase(transactionRepository)
}
@Provides
@ViewModelScoped
fun provideIsFeeApproximateUseCase(feeRepository: FeeRepository): IsFeeApproximateUseCase {
return IsFeeApproximateUseCase(feeRepository)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.di.domain
import com.tangem.domain.visa.GetVisaCurrencyUseCase
import com.tangem.domain.visa.repository.VisaRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
@Module
@InstallIn(ViewModelComponent::class)
internal object VisaDomainModule {
@Provides
fun provideVisaCurrencyUseCase(visaRepository: VisaRepository): GetVisaCurrencyUseCase {
return GetVisaCurrencyUseCase(visaRepository)
}
}

View file

@ -85,11 +85,15 @@ class TangemSdkManager(
).also { sendScanResultsToAnalytics(it) }
}
suspend fun createProductWallet(scanResponse: ScanResponse): CompletionResult<CreateProductWalletTaskResponse> {
suspend fun createProductWallet(
scanResponse: ScanResponse,
shouldReset: Boolean = false,
): CompletionResult<CreateProductWalletTaskResponse> {
return runTaskAsync(
runnable = CreateProductWalletTask(
cardTypesResolver = scanResponse.cardTypesResolver,
derivationStyleProvider = scanResponse.derivationStyleProvider,
shouldReset = shouldReset,
),
cardId = scanResponse.card.cardId,
initialMessage = Message(resources.getString(R.string.initial_message_create_wallet_body)),
@ -100,6 +104,7 @@ class TangemSdkManager(
suspend fun importWallet(
scanResponse: ScanResponse,
mnemonic: String,
shouldReset: Boolean,
): CompletionResult<CreateProductWalletTaskResponse> {
val defaultMnemonic = try {
DefaultMnemonic(mnemonic, tangemSdk.wordlist)
@ -108,9 +113,10 @@ class TangemSdkManager(
}
return runTaskAsync(
CreateProductWalletTask(
scanResponse.cardTypesResolver,
cardTypesResolver = scanResponse.cardTypesResolver,
derivationStyleProvider = scanResponse.derivationStyleProvider,
defaultMnemonic,
mnemonic = defaultMnemonic,
shouldReset = shouldReset,
),
scanResponse.card.cardId,
Message(resources.getString(R.string.initial_message_create_wallet_body)),

View file

@ -1,38 +0,0 @@
package com.tangem.tap.domain.tasks
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.guard
import com.tangem.operations.PreflightReadMode
import com.tangem.operations.PreflightReadTask
import com.tangem.operations.wallet.CreateWalletTask
@Deprecated("Use CreateProductWalletAndRescanTask instead")
class CreateWalletAndRescanTask : CardSessionRunnable<Card> {
override fun run(session: CardSession, callback: (result: CompletionResult<Card>) -> Unit) {
val card = session.environment.card.guard {
callback(CompletionResult.Failure(TangemSdkError.CardError()))
return
}
val firmwareVersion = card.firmwareVersion
val task = if (firmwareVersion < FirmwareVersion.MultiWalletAvailable) {
CreateWalletTask(card.supportedCurves.first())
} else {
CreateWalletsTask()
}
task.run(session) { result ->
when (result) {
is CompletionResult.Success ->
PreflightReadTask(PreflightReadMode.FullCardRead).run(session, callback)
is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error))
}
}
}
}

View file

@ -1,49 +0,0 @@
package com.tangem.tap.domain.tasks
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.operations.PreflightReadMode
import com.tangem.operations.PreflightReadTask
import com.tangem.operations.wallet.CreateWalletTask
@Deprecated("Use CreateProductWalletTask instead")
class CreateWalletsTask(curves: List<EllipticCurve>? = null) : CardSessionRunnable<Card> {
private val curves = curves ?: listOf(
EllipticCurve.Secp256k1,
EllipticCurve.Ed25519,
EllipticCurve.Secp256r1,
)
private var index = 0
override fun run(session: CardSession, callback: (result: CompletionResult<Card>) -> Unit) {
val curve = curves[index]
createWallet(curve, session, callback)
}
private fun createWallet(
curve: EllipticCurve,
session: CardSession,
callback: (result: CompletionResult<Card>) -> Unit,
) {
CreateWalletTask(curve).run(session) { result ->
when (result) {
is CompletionResult.Success -> {
if (index == curves.lastIndex) {
PreflightReadTask(PreflightReadMode.FullCardRead).run(session, callback)
return@run
}
index += 1
createWallet(curves[index], session, callback)
}
is CompletionResult.Failure -> {
callback(CompletionResult.Failure(result.error))
}
}
}
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.common.card.CardWallet
import com.tangem.common.card.EllipticCurve
class CardInitializationValidator(private val expectedCurves: List<EllipticCurve>) {
fun validateWallets(wallets: List<CardWallet>): Boolean {
val curves = wallets.map { it.curve }.toSet()
return curves.size == expectedCurves.size &&
curves.containsAll(expectedCurves)
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.card.EllipticCurve
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.TangemSdkError
@ -24,6 +25,7 @@ import com.tangem.operations.backup.PrimaryCard
import com.tangem.operations.backup.StartPrimaryCardLinkingCommand
import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.operations.read.ReadWalletsListCommand
import com.tangem.operations.wallet.CreateWalletTask
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.operations.wallet.CreateWalletResponse as SdkCreateWalletResponse
@ -60,6 +62,7 @@ class CreateProductWalletTask(
private val cardTypesResolver: CardTypesResolver,
private val derivationStyleProvider: DerivationStyleProvider,
private val mnemonic: Mnemonic? = null,
private val shouldReset: Boolean,
) : CardSessionRunnable<CreateProductWalletTaskResponse> {
override val allowsRequestAccessCodeFromRepository: Boolean = false
@ -79,7 +82,7 @@ class CreateProductWalletTask(
cardTypesResolver.isTangemTwins() ->
throw UnsupportedOperationException("Use the TwinCardsManager to create a wallet")
else -> CreateWalletTangemWallet(mnemonic, derivationStyleProvider)
else -> CreateWalletTangemWallet(mnemonic, shouldReset, derivationStyleProvider, cardDto)
}
commandProcessor.proceed(cardDto, session) {
when (it) {
@ -139,38 +142,38 @@ private class CreateWalletTangemNote(private val cardTypesResolver: CardTypesRes
*/
private class CreateWalletTangemWallet(
private val mnemonic: Mnemonic?,
private val shouldReset: Boolean,
private val derivationStyleProvider: DerivationStyleProvider,
cardDTO: CardDTO,
) : ProductCommandProcessor<CreateProductWalletTaskResponse> {
private var primaryCard: PrimaryCard? = null
private val cardConfig = CardConfig.createConfig(cardDTO)
override fun proceed(
card: CardDTO,
session: CardSession,
callback: (result: CompletionResult<CreateProductWalletTaskResponse>) -> Unit,
) {
val config = CardConfig.createConfig(card)
val walletsOnCard = card.wallets.map { it.curve }.toSet()
val curves = config.mandatoryCurves.toSet()
.intersect(card.supportedCurves.toSet())
.subtract(walletsOnCard).toList()
if (curves.isEmpty()) {
val createWalletResponses = card.wallets.map { wallet ->
CreateWalletResponse(card.cardId, wallet)
}
proceedWithCreatedWallets(card, createWalletResponses, session, callback)
return
if (walletsOnCard.isEmpty()) {
createMultiWallet(card, session, callback)
} else if (shouldReset) {
resetCard(card, session, callback)
} else {
callback(CompletionResult.Failure(TangemSdkError.WalletAlreadyCreated()))
}
CreateWalletsTask(curves, mnemonic).run(session) { result ->
}
private fun createMultiWallet(
card: CardDTO,
session: CardSession,
callback: (result: CompletionResult<CreateProductWalletTaskResponse>) -> Unit,
) {
CreateWalletsTask(cardConfig.mandatoryCurves, mnemonic).run(session) { result ->
when (result) {
is CompletionResult.Success -> {
proceedWithCreatedWallets(
card = card,
createWalletResponses = result.data.createWalletResponses.map { CreateWalletResponse(it) },
session = session,
callback = callback,
)
checkIfAllWalletsCreated(card, session, result.data, callback)
}
is CompletionResult.Failure -> {
callback(CompletionResult.Failure(result.error))
@ -179,6 +182,61 @@ private class CreateWalletTangemWallet(
}
}
private fun checkIfAllWalletsCreated(
card: CardDTO,
session: CardSession,
createResponse: CreateWalletsResponse,
callback: (result: CompletionResult<CreateProductWalletTaskResponse>) -> Unit,
) {
if (card.firmwareVersion < FirmwareVersion.MultiWalletAvailable) {
proceedWithCreatedWallets(
card = card,
createWalletResponses = createResponse.createWalletResponses.map { CreateWalletResponse(it) },
session = session,
callback = callback,
)
return
}
val command = ReadWalletsListCommand()
command.run(session) { response ->
when (response) {
is CompletionResult.Success -> {
val cardInitializationValidator = CardInitializationValidator(cardConfig.mandatoryCurves)
if (cardInitializationValidator.validateWallets(response.data.wallets)) {
proceedWithCreatedWallets(
card = card,
createWalletResponses = createResponse.createWalletResponses.map {
CreateWalletResponse(it)
},
session = session,
callback = callback,
)
} else {
callback(CompletionResult.Failure(TangemSdkError.WalletAlreadyCreated()))
}
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(response.error))
}
}
}
private fun resetCard(
card: CardDTO,
session: CardSession,
callback: (result: CompletionResult<CreateProductWalletTaskResponse>) -> Unit,
) {
val resetCommand = ResetToFactorySettingsTask(allowsRequestAccessCodeFromRepository = false)
resetCommand.run(session) {
when (it) {
is CompletionResult.Success -> {
createMultiWallet(card, session, callback)
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(it.error))
}
}
}
private fun proceedWithCreatedWallets(
card: CardDTO,
createWalletResponses: List<CreateWalletResponse>,

View file

@ -313,7 +313,12 @@ internal sealed class AddCustomTokenWarning(val description: TextReference) {
/**
* Floating button of add custom token screen
*
* @property isEnabled button availability
* @property onClick lambda be invoked when button is been pressed
* @property isEnabled button availability
* @property showProgress whether circle progress indication is enabled
* @property onClick lambda be invoked when button is been pressed
*/
internal data class AddCustomTokenFloatingButton(val isEnabled: Boolean, val onClick: () -> Unit)
internal data class AddCustomTokenFloatingButton(
val isEnabled: Boolean,
val showProgress: Boolean,
val onClick: () -> Unit,
)

View file

@ -91,7 +91,11 @@ internal object AddCustomTokenPreviewData {
),
form = createDefaultForm(),
warnings = createWarnings(),
floatingButton = AddCustomTokenFloatingButton(isEnabled = false, onClick = {}),
floatingButton = AddCustomTokenFloatingButton(
isEnabled = false,
showProgress = false,
onClick = {},
),
testBlock = AddCustomTokenTestBlock(
chooseTokenButtonText = "Choose token",
clearButtonText = "Clear address",
@ -112,7 +116,11 @@ internal object AddCustomTokenPreviewData {
),
form = createDefaultForm(),
warnings = createWarnings(),
floatingButton = AddCustomTokenFloatingButton(isEnabled = false, onClick = {}),
floatingButton = AddCustomTokenFloatingButton(
isEnabled = false,
showProgress = false,
onClick = {},
),
)
}
}

View file

@ -32,6 +32,7 @@ internal fun AddCustomTokenFloatingButton(model: AddCustomTokenFloatingButton, m
text = stringResource(id = R.string.custom_token_add_token),
iconResId = R.drawable.ic_plus_24,
enabled = model.isEnabled,
showProgress = model.showProgress,
onClick = model.onClick,
)
}
@ -48,7 +49,7 @@ private fun Preview_AddCustomTokenFloatingButton(
private class AddCustomTokenFloatingButtonProvider : CollectionPreviewParameterProvider<AddCustomTokenFloatingButton>(
listOf(
AddCustomTokenFloatingButton(isEnabled = true, onClick = {}),
AddCustomTokenFloatingButton(isEnabled = false, onClick = {}),
AddCustomTokenFloatingButton(isEnabled = true, showProgress = false, onClick = {}),
AddCustomTokenFloatingButton(isEnabled = false, showProgress = false, onClick = {}),
),
)

View file

@ -142,7 +142,11 @@ internal class AddCustomTokenViewModel @Inject constructor(
}
private fun createFloatingButton(): AddCustomTokenFloatingButton {
return AddCustomTokenFloatingButton(isEnabled = false, onClick = actionsHandler::onAddCustomTokenClick)
return AddCustomTokenFloatingButton(
isEnabled = true,
showProgress = false,
onClick = actionsHandler::onAddCustomTokenClick,
)
}
private inner class FormStateBuilder {
@ -852,9 +856,14 @@ internal class AddCustomTokenViewModel @Inject constructor(
analyticsSender.sendWhenAddTokenButtonClicked(currency)
viewModelScope.launch(dispatchers.io) {
val oldButtonState = uiState.floatingButton
uiState = uiState.copySealed(
floatingButton = uiState.floatingButton.copy(isEnabled = false, showProgress = true),
)
runCatching { featureInteractor.saveToken(currency) }
.onSuccess { featureRouter.openWalletScreen() }
.onFailure {
uiState = uiState.copySealed(floatingButton = oldButtonState)
Timber.e(it)
}
}

View file

@ -63,7 +63,8 @@ internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier
checked = item.isChecked,
enabled = item.isEnabled,
onCheckedChange = item.onCheckedChange,
checkedColor = TangemTheme.colors.icon.accent,
checkedColor = TangemTheme.colors.control.checked,
uncheckedColor = TangemTheme.colors.icon.inactive,
)
}
}

View file

@ -27,9 +27,9 @@ internal class ResetCardFragment : ComposeFragment(), StoreSubscriber<DetailsSta
@Composable
override fun ScreenContent(modifier: Modifier) {
ResetCardScreen(
modifier = modifier,
state = screenState.value,
onBackClick = { store.dispatch(NavigationAction.PopBackTo()) },
modifier = modifier,
)
}

View file

@ -1,27 +1,23 @@
package com.tangem.tap.features.details.ui.resetcard
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.IconToggleButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
import com.tangem.tap.features.details.ui.common.DetailsMainButton
import com.tangem.tap.features.details.ui.common.ScreenTitle
import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold
import com.tangem.wallet.R
@ -31,9 +27,7 @@ internal fun ResetCardScreen(state: ResetCardScreenState, onBackClick: () -> Uni
modifier = modifier,
content = {
when (state) {
is ResetCardScreenState.ResetCardScreenContent -> {
ResetCardView(state = state)
}
is ResetCardScreenState.ResetCardScreenContent -> ResetCardView(state = state)
ResetCardScreenState.InitialState -> {
// do nothing for now, just white screen
}
@ -43,83 +37,86 @@ internal fun ResetCardScreen(state: ResetCardScreenState, onBackClick: () -> Uni
)
}
@Suppress("LongMethod", "MagicNumber")
@Composable
private fun ResetCardView(state: ResetCardScreenState.ResetCardScreenContent) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween,
.verticalScroll(scrollState) // set scrollState after fillMaxSize
.padding(horizontal = TangemTheme.dimens.spacing20),
) {
ScreenTitle(titleRes = R.string.card_settings_reset_card_to_factory)
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 21.dp),
contentAlignment = Alignment.CenterStart,
) {
Icon(
painter = painterResource(id = R.drawable.img_alert),
contentDescription = "",
tint = Color.Unspecified,
)
}
Column(
modifier = Modifier.offset(y = (-32).dp),
verticalArrangement = Arrangement.Bottom,
) {
Text(
text = stringResource(id = R.string.common_attention),
modifier = Modifier.padding(start = 20.dp, end = 20.dp),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
Title()
SpacerH24()
AlertImage()
SpacerH24()
Subtitle()
SpacerH16()
Description(text = state.descriptionText)
SpacerH12()
Conditions(state)
DynamicSpacer(scrollState = scrollState)
SpacerH16()
ResetButton(enabled = state.resetButtonEnabled, onResetButtonClick = state.onResetButtonClick)
SpacerH16()
}
}
Spacer(modifier = Modifier.size(24.dp))
@Composable
private fun Title() {
Text(
text = stringResource(id = R.string.card_settings_reset_card_to_factory),
style = TangemTheme.typography.h1,
color = TangemTheme.colors.text.primary1,
)
}
Text(
text = state.descriptionText.resolveReference(),
modifier = Modifier.padding(start = 20.dp, end = 20.dp),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
)
@Composable
private fun AlertImage() {
Image(
painter = painterResource(id = R.drawable.img_alert_80),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size80),
)
}
Spacer(modifier = Modifier.size(28.dp))
@Composable
private fun Subtitle() {
Text(
text = stringResource(id = R.string.common_attention),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
}
state.warningsToShow.forEach {
when (it) {
ResetCardScreenState.WarningsToReset.LOST_WALLET_ACCESS -> {
ConditionCheckBox(
checkedState = state.acceptCondition1Checked,
onCheckedChange = state.onAcceptCondition1ToggleClick,
description = TextReference.Res(R.string.reset_card_to_factory_condition_1),
)
}
ResetCardScreenState.WarningsToReset.LOST_PASSWORD_RESTORE -> {
ConditionCheckBox(
checkedState = state.acceptCondition2Checked,
onCheckedChange = state.onAcceptCondition2ToggleClick,
description = TextReference.Res(R.string.reset_card_to_factory_condition_2),
)
}
}
@Composable
private fun Description(text: TextReference) {
Text(
text = text.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
)
}
@Composable
private fun Conditions(state: ResetCardScreenState.ResetCardScreenContent) {
state.warningsToShow.forEach {
when (it) {
ResetCardScreenState.WarningsToReset.LOST_WALLET_ACCESS -> {
ConditionCheckBox(
checkedState = state.acceptCondition1Checked,
onCheckedChange = state.onAcceptCondition1ToggleClick,
description = TextReference.Res(R.string.reset_card_to_factory_condition_1),
)
}
Spacer(modifier = Modifier.size(16.dp))
Box(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
) {
AnimatedContent(
targetState = state.resetButtonEnabled,
label = "Update checked state",
) { buttonEnabled ->
DetailsMainButton(
title = stringResource(id = R.string.reset_card_to_factory_button_title),
onClick = state.onResetButtonClick,
enabled = buttonEnabled,
)
}
ResetCardScreenState.WarningsToReset.LOST_PASSWORD_RESTORE -> {
ConditionCheckBox(
checkedState = state.acceptCondition2Checked,
onCheckedChange = state.onAcceptCondition2ToggleClick,
description = TextReference.Res(R.string.reset_card_to_factory_condition_2),
)
}
}
}
@ -130,16 +127,11 @@ private fun ConditionCheckBox(checkedState: Boolean, onCheckedChange: (Boolean)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { onCheckedChange.invoke(!checkedState) },
)
.padding(top = TangemTheme.dimens.size16, bottom = TangemTheme.dimens.size16),
.clickable(onClick = { onCheckedChange.invoke(!checkedState) })
.padding(vertical = TangemTheme.dimens.size16),
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing16),
) {
IconToggleButton(
checked = checkedState,
onCheckedChange = onCheckedChange,
modifier = Modifier.padding(start = TangemTheme.dimens.size20, end = TangemTheme.dimens.size20),
) {
IconToggleButton(checked = checkedState, onCheckedChange = onCheckedChange) {
AnimatedContent(targetState = checkedState, label = "Update checked state") { checked ->
Icon(
painter = painterResource(
@ -151,22 +143,44 @@ private fun ConditionCheckBox(checkedState: Boolean, onCheckedChange: (Boolean)
),
contentDescription = null,
tint = if (checked) {
TangemTheme.colors.icon.accent
TangemTheme.colors.control.checked
} else {
TangemTheme.colors.icon.secondary
},
)
}
}
Text(
text = description.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier.padding(end = TangemTheme.dimens.size20),
)
}
}
/**
* It's helps to create an adaptive layout.
* ResetButton will be attach to the bottom of large screen or will be inside scroll layout of small screen.
*
* @param scrollState flag determines if screen is small (has scroll) or large (hasn't scroll)
*/
@Composable
private fun ColumnScope.DynamicSpacer(scrollState: ScrollState) {
if (!scrollState.canScrollBackward && !scrollState.canScrollForward) {
Spacer(modifier = Modifier.weight(1f))
}
}
@Composable
private fun ResetButton(enabled: Boolean, onResetButtonClick: () -> Unit) {
DetailsMainButton(
title = stringResource(id = R.string.reset_card_to_factory_button_title),
onClick = onResetButtonClick,
enabled = enabled,
)
}
// region Preview
@Composable
private fun ResetCardScreenSample(modifier: Modifier = Modifier) {

View file

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

View file

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

View file

@ -9,4 +9,5 @@ import com.tangem.core.navigation.StateDialog
sealed class OnboardingDialog : StateDialog {
object TwinningProcessNotCompleted : OnboardingDialog()
data class InterruptOnboarding(val onOk: VoidCallback) : OnboardingDialog()
data class WalletActivationError(val onConfirm: () -> Unit) : OnboardingDialog()
}

View file

@ -120,7 +120,10 @@ private fun handleWalletAction(action: Action) {
is OnboardingWalletAction.CreateWallet -> {
scanResponse ?: return
scope.launch {
val result = tangemSdkManager.createProductWallet(scanResponse)
val result = tangemSdkManager.createProductWallet(
scanResponse,
globalState.onboardingState.shouldResetOnCreate,
)
store.dispatchOnMain(OnboardingWalletAction.WalletWasCreated(true, result))
}
}
@ -139,10 +142,15 @@ private fun handleWalletAction(action: Action) {
)
onboardingManager.scanResponse = updatedResponse
store.dispatch(GlobalAction.Onboarding.ShouldResetCardOnCreate(false))
startCardActivation(updatedResponse)
store.dispatch(OnboardingWalletAction.ResumeBackup)
}
is CompletionResult.Failure -> Unit
is CompletionResult.Failure -> {
if (result.error is TangemSdkError.WalletAlreadyCreated) {
handleActivationError()
}
}
}
}
is OnboardingWalletAction.FinishOnboarding -> {
@ -218,9 +226,15 @@ private fun handleWallet2Action(action: OnboardingWallet2Action) {
is OnboardingWallet2Action.CreateWallet -> {
scanResponse ?: return
scope.launch {
val mediateResult = when (val result = tangemSdkManager.createProductWallet(scanResponse)) {
val mediateResult = when (
val result = tangemSdkManager.createProductWallet(
scanResponse,
globalState.onboardingState.shouldResetOnCreate,
)
) {
is CompletionResult.Success -> {
Analytics.send(Onboarding.CreateWallet.WalletCreatedSuccessfully())
store.dispatch(GlobalAction.Onboarding.ShouldResetCardOnCreate(false))
val response = CreateWalletResponse(
card = result.data.card,
derivedKeys = result.data.derivedKeys,
@ -230,6 +244,9 @@ private fun handleWallet2Action(action: OnboardingWallet2Action) {
}
is CompletionResult.Failure -> {
if (result.error is TangemSdkError.WalletAlreadyCreated) {
handleActivationError()
}
CompletionResult.Failure(result.error)
}
}
@ -246,6 +263,7 @@ private fun handleWallet2Action(action: OnboardingWallet2Action) {
val result = tangemSdkManager.importWallet(
scanResponse = scanResponse,
mnemonic = action.mnemonicComponents.joinToString(" "),
shouldReset = globalState.onboardingState.shouldResetOnCreate,
)
) {
is CompletionResult.Success -> {
@ -259,6 +277,7 @@ private fun handleWallet2Action(action: OnboardingWallet2Action) {
seedPhraseLength = action.mnemonicComponents.size,
),
)
store.dispatch(GlobalAction.Onboarding.ShouldResetCardOnCreate(false))
val response = CreateWalletResponse(
card = result.data.card,
derivedKeys = result.data.derivedKeys,
@ -268,6 +287,9 @@ private fun handleWallet2Action(action: OnboardingWallet2Action) {
}
is CompletionResult.Failure -> {
if (result.error is TangemSdkError.WalletAlreadyCreated) {
handleActivationError()
}
CompletionResult.Failure(result.error)
}
}
@ -300,6 +322,16 @@ private fun handleWallet2Action(action: OnboardingWallet2Action) {
}
}
private fun handleActivationError() {
store.dispatchDialogShow(
OnboardingDialog.WalletActivationError(
onConfirm = {
store.dispatch(GlobalAction.Onboarding.ShouldResetCardOnCreate(true))
},
),
)
}
private fun updateScanResponseAfterBackup(scanResponse: ScanResponse, backupState: BackupState): ScanResponse {
val card = if (backupState.backupCardsNumber > 0) {
val cardsCount = backupState.backupCardsNumber

View file

@ -1,7 +1,7 @@
package com.tangem.tap.features.onboarding.products.wallet.ui
import androidx.compose.runtime.collectAsState
import com.tangem.feature.onboarding.api.OnboardingSeedPhrase
import com.tangem.feature.onboarding.api.OnboardingSeedPhraseScreen
import com.tangem.feature.onboarding.api.OnboardingSeedPhraseApi
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.SeedPhraseScreen
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.SeedPhraseViewModel
@ -15,7 +15,7 @@ import com.tangem.wallet.R
[REDACTED_AUTHOR]
*/
internal class OnboardingSeedPhraseStateHandler(
private val onboardingSeedPhraseApi: OnboardingSeedPhraseApi = OnboardingSeedPhrase(),
private val onboardingSeedPhraseApi: OnboardingSeedPhraseApi = OnboardingSeedPhraseScreen(),
) {
fun newState(

View file

@ -0,0 +1,27 @@
package com.tangem.tap.features.onboarding.products.wallet.ui.dialogs
import android.app.Dialog
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.tangem.tap.common.extensions.dispatchDialogHide
import com.tangem.tap.common.feedback.SupportInfo
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.onboarding.OnboardingDialog
import com.tangem.tap.store
import com.tangem.wallet.R
object WalletActivationErrorDialog {
fun create(context: Context, dialog: OnboardingDialog.WalletActivationError): Dialog {
return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply {
setTitle(context.getString(R.string.onboarding_activation_error_title))
setMessage(context.getString(R.string.onboarding_activation_error_message))
setPositiveButton(R.string.common_ok) { _, _ -> dialog.onConfirm() }
setNegativeButton(R.string.chat_button_title) { _, _ ->
store.dispatch(GlobalAction.OpenChat(SupportInfo()))
}
setOnDismissListener { store.dispatchDialogHide() }
setCancelable(false)
}.create()
}
}

View file

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

View file

@ -22,20 +22,16 @@ data class MercuryoCurrenciesResponse(
)
data class Config(
val base: Map<String, String>,
@Json(name = "has_withdrawal_fee")
val hasWithdrawalFee: Map<String, Boolean>,
@Json(name = "display_options")
val displayOptions: Map<String, DisplayOption>,
val icons: Map<String, Any>,
@Json(name = "crypto_currencies")
val cryptoCurrencies: List<MercuryoCryptoCurrency>,
)
data class DisplayOption(
@Json(name = "fullname")
val fullName: String,
@Json(name = "total_digits")
val totalDigits: Int,
@Json(name = "display_digits")
val displayDigits: Int,
data class MercuryoCryptoCurrency(
@Json(name = "currency")
val currencySymbol: String,
@Json(name = "network")
val network: String,
@Json(name = "contract")
val contractAddress: String,
)
}

View file

@ -11,7 +11,6 @@ import com.tangem.tap.domain.model.Currency
import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager
import com.tangem.tap.network.exchangeServices.ExchangeService
import com.tangem.tap.network.exchangeServices.ExchangeUrlBuilder
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
@ -21,8 +20,7 @@ internal class MercuryoService(private val environment: MercuryoEnvironment) : E
private val api: MercuryoApi = environment.mercuryoApi
private val blockchainsAvailableToBuy = CopyOnWriteArrayList<Blockchain>()
private val tokensAvailableToBuy = ConcurrentHashMap<String, List<Blockchain>>()
private val availableMercuryoCurrencies = CopyOnWriteArrayList<MercuryoCurrenciesResponse.MercuryoCryptoCurrency>()
override fun featureIsSwitchedOn(): Boolean = true
@ -33,29 +31,14 @@ internal class MercuryoService(private val environment: MercuryoEnvironment) : E
override fun availableForBuy(currency: Currency): Boolean {
if (!isBuyAllowed()) return false
// blockchains which cant be defined by mercuryo service
val unsupportedBlockchains = listOf(
Blockchain.Unknown,
Blockchain.Binance,
Blockchain.Arbitrum,
Blockchain.Optimism,
)
val blockchain = currency.blockchain
return when (currency) {
is Currency.Blockchain -> {
when {
blockchain.isTestnet() -> blockchain.getTestnetTopUpUrl() != null
unsupportedBlockchains.contains(blockchain) -> false
else -> blockchainsAvailableToBuy.contains(blockchain)
}
}
is Currency.Token -> {
val supportedInBlockchains = tokensAvailableToBuy[currency.currencySymbol] ?: return false
supportedInBlockchains.contains(blockchain)
}
val mercuryoNetwork = currency.blockchain.mercuryoNetwork()
val contractAddress = (currency as? Currency.Token)?.token?.contractAddress ?: ""
val availableCurrency = availableMercuryoCurrencies.firstOrNull {
it.currencySymbol == currency.currencySymbol &&
it.network == mercuryoNetwork &&
it.contractAddress == contractAddress
}
return availableCurrency != null
}
override fun availableForSell(currency: Currency): Boolean = false
@ -67,8 +50,7 @@ internal class MercuryoService(private val environment: MercuryoEnvironment) : E
handleSuccessfullyUpdatedData(data = result.data.data)
}
result is Result.Failure -> {
blockchainsAvailableToBuy.clear()
tokensAvailableToBuy.clear()
availableMercuryoCurrencies.clear()
}
}
}
@ -95,34 +77,48 @@ internal class MercuryoService(private val environment: MercuryoEnvironment) : E
.appendQueryParameter("return_url", ExchangeUrlBuilder.SUCCESS_URL)
if (isDarkTheme) builder.appendQueryParameter("theme", "1inch")
blockchain.mercuryoNetwork()?.let {
builder.appendQueryParameter("network", it)
}
return builder.build().toString()
}
override fun getSellCryptoReceiptUrl(action: CurrencyExchangeManager.Action, transactionId: String): String? = null
private fun handleSuccessfullyUpdatedData(data: MercuryoCurrenciesResponse.Data) {
data.crypto.forEach { currencyName ->
val blockchain = blockchainFromCurrencyName(currencyName)
if (blockchain == null) {
val specificBlockchain = data.config.base[currencyName]?.let(::blockchainFromCurrencyName)
if (specificBlockchain != null) {
tokensAvailableToBuy.set(
key = currencyName,
value = tokensAvailableToBuy[currencyName].orEmpty() + specificBlockchain,
)
}
} else {
blockchainsAvailableToBuy.add(blockchain)
}
}
availableMercuryoCurrencies.clear()
availableMercuryoCurrencies.addAll(data.config.cryptoCurrencies)
}
private fun blockchainFromCurrencyName(currencyName: String): Blockchain? {
return when (currencyName) {
"BNB" -> Blockchain.BSC
"ETH" -> Blockchain.Ethereum
"ADA" -> Blockchain.Cardano
else -> Blockchain.values().find { it.currency.lowercase() == currencyName.lowercase() }
@Suppress("CyclomaticComplexMethod")
private fun Blockchain.mercuryoNetwork(): String? {
return when (this) {
// Blockchain.Algorand -> "ALGORAND" //TODO: Uncomment with algo support
Blockchain.Arbitrum -> "ARBITRUM"
Blockchain.Avalanche -> "AVALANCHE"
Blockchain.BSC -> "BINANCESMARTCHAIN"
Blockchain.Bitcoin -> "BITCOIN"
Blockchain.BitcoinCash -> "BITCOINCASH"
Blockchain.Cardano -> "CARDANO"
Blockchain.Cosmos -> "COSMOS"
Blockchain.Dash -> "DASH"
Blockchain.Dogecoin -> "DOGECOIN"
Blockchain.Ethereum -> "ETHEREUM"
Blockchain.Fantom -> "FANTOM"
Blockchain.Kusama -> "KUSAMA"
Blockchain.Litecoin -> "LITECOIN"
Blockchain.Near -> "NEAR_PROTOCOL"
Blockchain.TON -> "NEWTON"
Blockchain.Optimism -> "OPTIMISM"
Blockchain.Polkadot -> "POLKADOT"
Blockchain.Polygon -> "POLYGON"
Blockchain.XRP -> "RIPPLE"
Blockchain.Solana -> "SOLANA"
Blockchain.Stellar -> "STELLAR"
Blockchain.Tezos -> "TEZOS"
Blockchain.Tron -> "TRON"
else -> null
}
}

View file

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

View file

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

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

@ -0,0 +1 @@
/build

View file

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

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

@ -0,0 +1 @@
/build

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -70,7 +70,7 @@
<string name="common_biometric_authentication">биометрическую аутентификацию</string>
<string name="common_biometrics">биометрией</string>
<string name="common_buy">Купить</string>
<string name="common_buy_currency">Купить %1$s</string>
<string name="common_buy_currency">Перейти на %1$s</string>
<string name="common_camera_denied_alert_message">Вы не предоставили доступ к камере, пожалуйста, измените настройки конфиденциальности.</string>
<string name="common_cancel">Отмена</string>
<string name="common_close">Закрыть</string>
@ -136,6 +136,7 @@
<string name="common_transfer">Перевод</string>
<string name="common_understand">Я понял</string>
<string name="common_unreachable">Недоступно</string>
<string name="common_unknown_error">Произошла ошибка. Пожалуйста, попробуйте снова.</string>
<string name="common_yes">Да</string>
<string name="contract_address_copied_message">Адрес контракта скопирован!</string>
<string name="currency_subtitle_expanded">Доступные сети</string>
@ -231,6 +232,7 @@
<string name="express_provider">Провайдер</string>
<string name="express_provider_best_rate">Лучший курс</string>
<string name="express_provider_min_amount">Доступно с %s</string>
<string name="express_provider_max_amount">Доступно до %s</string>
<string name="express_provider_not_available">Недоступно для этой пары</string>
<string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="express_terms_of_use">Условиями использования</string>
@ -306,6 +308,8 @@
<string name="onboarding_access_code_repeat_code_title">Повторно введите код доступа</string>
<string name="onboarding_access_code_too_short">Код доступа должен состоять не менее чем из 4 символов.</string>
<string name="onboarding_access_codes_doesnt_match">Введенные коды доступа не совпадают</string>
<string name="onboarding_activation_error_message">Необходимо повторить операцию, при этом карта будет сброшена к заводским настройкам</string>
<string name="onboarding_activation_error_title">Ошибка активации</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">Вы добавили одну резервную карту. После того, как процесс будет завершен, Вы больше не сможете добавить карт. Если у Вас есть еще одна карта, добавьте ее в резервную копию. Хотите продолжить?</string>
<string name="onboarding_backup_exit_warning">Процесс резервного копирования почти завершен. Вы не можете выйти из него сейчас.</string>
<string name="onboarding_button_add_backup_card">Добавить резервную карту</string>
@ -448,8 +452,9 @@
<string name="send_amount_label">Сумма</string>
<string name="send_amount_substract">Вычесть из суммы отправки</string>
<string name="send_amount_substract_footer">Сумма к получению %s</string>
<string name="send_alert_button_request_support">Поддержка</string>
<string name="send_alert_transaction_failed_title">Транзакция не выполнена</string>
<string name="send_alert_transaction_failed_text">Причина: %1$s\Код:%2$s</string>
<string name="send_alert_transaction_failed_text">Причина: %1$s\nКод: %2$s</string>
<string name="send_date_format">%1$s в %2$s</string>
<string name="send_destination_hint_address">Адрес</string>
<string name="send_destination_tag_field">Код назначения</string>
@ -475,10 +480,10 @@
<string name="send_network_fee_warning_title">Покрытие сетевой комиссии</string>
<string name="send_notification_exceed_balance_text">Недостаточно средств для перевода, так как сумма комиссии и сумма перевода в совокупности больше имеющегося баланса</string>
<string name="send_notification_exceed_balance_title">Недостаточно средств</string>
<string name="send_notification_exceed_fee_text">Размер комиссии превышает баланс сети. Для продолжения необходимо пополнить баланс сети.</string>
<string name="send_notification_exceed_fee_title">Комиссия превышает баланс</string>
<string name="send_notification_high_fee_text">Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01.</string>
<string name="send_notification_high_fee_title">Увеличение комиссии</string>
<string name="send_notification_high_fee_text">Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01.</string>
<string name="send_notification_fee_too_high_accept">Оставить %s XTZ</string>
<string name="send_notification_fee_too_high_ignore">Отправить все</string>
<string name="send_notification_fee_too_high_title">Установлена высокая комиссия</string>
<string name="send_notification_fee_too_high_text">Сумма комиссии в %s раз превышает рекомендованную. Убедитесь, что указанная комиссия верна.</string>
<string name="send_notification_invalid_amount_text">Включенная комиссия превышает сумму перевода, что приводит к отрицательному значению</string>
@ -489,7 +494,6 @@
<string name="send_notification_transaction_delay_text">Обратите внимание, что при определенных параметрах комиссии возможны задержки по вашей транзакции</string>
<string name="send_notification_transaction_delay_title">Возможны задержки по транзакции</string>
<string name="send_optional_field">Необязательное</string>
<string name="send_qrcode_scan_amount_alert_title">QR код содержит информацию о сумме отправки равной %s</string>
<string name="send_recent_transactions">Последние</string>
<string name="send_recipient">Получатель</string>
<string name="send_recipient_address_footer">Убедитесь, что вы отправляете средства на адрес кошелька %s. Ошибки могут привести к потере ваших токенов</string>
@ -528,7 +532,6 @@
<string name="swapping_approve_information_title">Подтвердить</string>
<string name="swapping_error_wrapper">Ошибка: %s</string>
<string name="swapping_from_title">Вы отправляете</string>
<string name="swapping_generic_error">Произошла ошибка. Пожалуйста, попробуйте еще раз.</string>
<string name="swapping_give_permission">Дать разрешение</string>
<string name="swapping_high_price_impact_description">Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму.</string>
<string name="swapping_insufficient_funds">Недостаточно средств</string>
@ -651,7 +654,7 @@
<string name="wallet_connect_service_no_chain_id">Dapp не предоставил необходимые данные для открытия сессии WalletConnect</string>
<string name="wallet_connect_session_not_found">Не удалось найти сессию для обработки запроса</string>
<string name="wallet_connect_sessions_title">Сессии WalletConnect</string>
<string name="wallet_connect_subtitle">Подключение к Dapps</string>
<string name="wallet_connect_subtitle">Подключение к dApps</string>
<string name="wallet_connect_title">WalletConnect</string>
<string name="wallet_connect_transaction_signed">Транзакция успешно подписана и отправлена ​​в Dapp</string>
<string name="wallet_connect_transaction_signed_and_send">Транзакция успешно подписана и отправлена ​​в блокчейн</string>
@ -690,8 +693,9 @@
<string name="warning_express_not_enough_fee_for_token_tx_description">Чтобы совершить транзакцию, вам необходимо внести немного %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Невозможно покрыть комиссию %s</string>
<string name="warning_express_refresh_required_title">Cервис временно недоступен</string>
<string name="warning_express_too_minimal_amount_description">Пожалуйста, измените сумму для обмена</string>
<string name="warning_express_wrong_amount_description">Пожалуйста, измените сумму для обмена</string>
<string name="warning_express_too_minimal_amount_title">Сумма для обмена должна быть не менее %s</string>
<string name="warning_express_too_maximum_amount_title">Сумма для обмена должна быть не более %s</string>
<string name="warning_failed_to_verify_card_message">Возможно, данная карта - образец или подделка</string>
<string name="warning_failed_to_verify_card_title">Ошибка проверки подлинности</string>
<string name="warning_low_signatures_message">На этой карте осталось всего %s подписей. Вам следует вывести все ваши средства.</string>

View file

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

View file

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

View file

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

View file

@ -68,7 +68,7 @@
<string name="common_biometric_authentication">biometric authentication</string>
<string name="common_biometrics">biometrics</string>
<string name="common_buy">Buy</string>
<string name="common_buy_currency">Buy %1$s</string>
<string name="common_buy_currency">Go to %1$s</string>
<string name="common_camera_denied_alert_message">You have not given access to your camera, please adjust your privacy settings</string>
<string name="common_cancel">Cancel</string>
<string name="common_close">Close</string>
@ -135,6 +135,7 @@
<string name="common_transfer">Transfer</string>
<string name="common_understand">I understand</string>
<string name="common_unreachable">Unreachable</string>
<string name="common_unknown_error">There was an error. Please try again.</string>
<string name="common_yes">Yes</string>
<string name="contract_address_copied_message">Contract address copied!</string>
<string name="currency_subtitle_expanded">Available networks</string>
@ -234,6 +235,7 @@
<string name="express_provider">Provider</string>
<string name="express_provider_best_rate">Best rate</string>
<string name="express_provider_min_amount">Available from %s</string>
<string name="express_provider_max_amount">Available up to %s</string>
<string name="express_provider_not_available">Unavailable for this pair</string>
<string name="express_provider_permission_needed">Permission Required</string>
<string name="express_terms_of_use">Terms of Use</string>
@ -309,6 +311,8 @@
<string name="onboarding_access_code_repeat_code_title">Re-enter your Access Code</string>
<string name="onboarding_access_code_too_short">Access code must be at least 4 characters long</string>
<string name="onboarding_access_codes_doesnt_match">Entered access code didn\'t match the initial access code</string>
<string name="onboarding_activation_error_message">Please repeat the operation. The card will be reset to factory settings.</string>
<string name="onboarding_activation_error_title">Activation error</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">You\'ve added one backup card. When backup process is finished you can\'t add more backup cards. If you have one more card, add it to backup. Do you like to continue the backup process?</string>
<string name="onboarding_backup_exit_warning">The backup process is partly complete. You can\'t exit it now.</string>
<string name="onboarding_button_add_backup_card">Add a backup card</string>
@ -447,8 +451,9 @@
<string name="send_amount_label">Amount</string>
<string name="send_amount_substract">Subtract from send amount</string>
<string name="send_amount_substract_footer">The recipient will receive %s</string>
<string name="send_alert_button_request_support">Support</string>
<string name="send_alert_transaction_failed_title">The transaction is not completed</string>
<string name="send_alert_transaction_failed_text">Reason: %1$s\nCode:%2$s</string>
<string name="send_alert_transaction_failed_text">Reason: %1$s\nCode: %2$s</string>
<string name="send_confirm_label">Confirm</string>
<string name="send_date_format">%1$s at %2$s</string>
<string name="send_destination_hint_address">Address</string>
@ -480,10 +485,10 @@
<string name="send_network_fee_warning_title">Network fee coverage</string>
<string name="send_notification_exceed_balance_text">Insufficient funds for the transfer, as the total of the fee and transfer amount exceeds the existing balance</string>
<string name="send_notification_exceed_balance_title">Total exceeds balance</string>
<string name="send_notification_exceed_fee_text">The commission fee exceeds the network balance. To continue, it is necessary to replenish the network balance.</string>
<string name="send_notification_exceed_fee_title">Fee exceeds balance</string>
<string name="send_notification_high_fee_text">The fee for transferring the entire balance is higher. To reduce the commission, you can leave 0.01.</string>
<string name="send_notification_high_fee_title">Fee is increased</string>
<string name="send_notification_high_fee_text">The fee for transferring the entire balance is higher. To reduce the commission, you can leave 0.01.</string>
<string name="send_notification_fee_too_high_accept">Reduce by %s XTZ</string>
<string name="send_notification_fee_too_high_ignore">No, send all</string>
<string name="send_notification_fee_too_high_title">Custom fee is high</string>
<string name="send_notification_fee_too_high_text">The commission amount is %s times the recommended amount. Make sure that the custom settings are correct.</string>
<string name="send_notification_invalid_amount_text">The included commission exceeds the transfer amount, leading to a negative value</string>
@ -498,11 +503,6 @@
<string name="send_notification_existential_deposit_title">Existential deposit</string>
<string name="send_notification_existential_deposit_text">The account will be wiped from the blockchain if a balance goes below the existential deposit. Please ensure that the remaining balance after sending will not be less than %s.</string>
<string name="send_optional_field">Optional</string>
<string name="send_qrcode_alert_change">Change</string>
<string name="send_qrcode_alert_decline">Decline</string>
<string name="send_qrcode_scan_address_success">Recipients address scanned</string>
<string name="send_qrcode_scan_amount_alert_text">Change the entered amount?</string>
<string name="send_qrcode_scan_amount_alert_title">QR code contains information about the sending amount equal to %s</string>
<string name="send_qrcode_scan_info">Please align your QR code with the square to scan it. Ensure you scan %s network address.</string>
<string name="send_recent_transactions">Recent</string>
<string name="send_recipient">Recipient</string>
@ -519,6 +519,7 @@
<string name="send_transaction_success">Transaction has been successfully signed and sent to the blockchain node. Wallet balance will be updated in a while</string>
<string name="send_validation_invalid_address">Invalid address</string>
<string name="sent_transaction_sent_title">Transaction sent</string>
<string name="send_wallet_balance_format">%s (%s)</string>
<string name="shop_buy_now">Buy now</string>
<string name="shop_i_have_a_promo_code">I have a promo code…</string>
<string name="shop_one_wallet">Tangem Wallet</string>
@ -545,7 +546,6 @@
<string name="swapping_approve_information_title">Approve</string>
<string name="swapping_error_wrapper">Error: %s</string>
<string name="swapping_from_title">You swap</string>
<string name="swapping_generic_error">There was an error. Please try again.</string>
<string name="swapping_give_permission">Give Permission</string>
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_insufficient_funds">Insufficient funds</string>
@ -666,7 +666,7 @@
<string name="wallet_connect_service_no_chain_id">Dapp didn\'t provide essential data to establish WalletConnect session</string>
<string name="wallet_connect_session_not_found">Failed to find session for request</string>
<string name="wallet_connect_sessions_title">WalletConnect Sessions</string>
<string name="wallet_connect_subtitle">Connect to Dapps</string>
<string name="wallet_connect_subtitle">Connect to dApps</string>
<string name="wallet_connect_title">WalletConnect</string>
<string name="wallet_connect_transaction_signed">The transaction has been successfully signed and sent to the Dapp</string>
<string name="wallet_connect_transaction_signed_and_send">The transaction has been succesfully signed and sent to the blockchain</string>
@ -705,8 +705,9 @@
<string name="warning_express_not_enough_fee_for_token_tx_description">To make a transaction you need to deposit some %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Unable to cover %s fee</string>
<string name="warning_express_refresh_required_title">Service temporarily unavailable</string>
<string name="warning_express_too_minimal_amount_description">Please change the amount to swap</string>
<string name="warning_express_wrong_amount_description">Please change the amount to swap</string>
<string name="warning_express_too_minimal_amount_title">The amount to swap must be at least %s</string>
<string name="warning_express_too_maximum_amount_title">The amount of tokens to be swapped must not exceed %s</string>
<string name="warning_failed_to_verify_card_message">This card might be a production sample or counterfeit</string>
<string name="warning_failed_to_verify_card_title">Authenticity check failed</string>
<string name="warning_low_signatures_message">Only %s signatures are left on this card. You must withdraw all of your funds.</string>

View file

@ -7,7 +7,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.*
import androidx.compose.material.RadioButton
import androidx.compose.material.RadioButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
@ -210,7 +212,7 @@ private fun TangemDialog(
modifier = Modifier
.background(
shape = TangemTheme.shapes.roundedCornersLarge,
color = TangemTheme.colors.background.plain,
color = TangemTheme.colors.background.primary,
)
.padding(vertical = TangemTheme.dimens.spacing24),
) {
@ -386,7 +388,7 @@ private fun SelectorDialogContent(
selected = index == selectedItemIndex,
onClick = onClick,
colors = RadioButtonDefaults.colors(
selectedColor = TangemTheme.colors.icon.accent,
selectedColor = TangemTheme.colors.control.checked,
unselectedColor = TangemTheme.colors.icon.secondary,
),
interactionSource = interactionSource,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -42,7 +42,7 @@ class TangemColors internal constructor(
warning: Color,
attention: Color,
accent: Color = TangemColorPalette.Azure,
constantWhite: Color = TangemColorPalette.White,
constant: Color = TangemColorPalette.White,
) {
var primary1 by mutableStateOf(primary1)
private set
@ -60,7 +60,7 @@ class TangemColors internal constructor(
private set
var attention by mutableStateOf(attention)
private set
var constantWhite by mutableStateOf(constantWhite)
var constantWhite by mutableStateOf(constant)
private set
fun update(other: Text) {
@ -83,6 +83,7 @@ class TangemColors internal constructor(
warning: Color,
attention: Color,
accent: Color = TangemColorPalette.Azure,
constant: Color = TangemColorPalette.White,
) {
var primary1 by mutableStateOf(primary1)
private set
@ -118,8 +119,7 @@ class TangemColors internal constructor(
primary: Color,
secondary: Color,
disabled: Color,
positive: Color = TangemColorPalette.Meadow,
positiveDisabled: Color,
positive: Color = TangemColorPalette.Azure,
) {
var primary by mutableStateOf(primary)
private set
@ -129,15 +129,12 @@ class TangemColors internal constructor(
private set
var positive by mutableStateOf(positive)
private set
var positiveDisabled by mutableStateOf(positiveDisabled)
private set
fun update(other: Button) {
primary = other.primary
secondary = other.secondary
disabled = other.disabled
positive = other.positive
positiveDisabled = other.positiveDisabled
}
}
@ -146,9 +143,7 @@ class TangemColors internal constructor(
primary: Color,
secondary: Color,
tertiary: Color,
plain: Color,
action: Color,
fade: Color,
) {
var primary by mutableStateOf(primary)
private set
@ -156,20 +151,14 @@ class TangemColors internal constructor(
private set
var tertiary by mutableStateOf(tertiary)
private set
var plain by mutableStateOf(plain)
private set
var action by mutableStateOf(action)
private set
var fade by mutableStateOf(fade)
private set
fun update(other: Background) {
primary = other.primary
secondary = other.secondary
tertiary = other.tertiary
plain = other.plain
action = other.action
fade = other.fade
}
}

View file

@ -69,7 +69,7 @@ private fun materialThemeColors(colors: TangemColors, isDark: Boolean): Colors {
secondary = colors.button.primary,
secondaryVariant = colors.text.accent,
background = colors.background.primary,
surface = colors.background.plain,
surface = colors.background.secondary,
error = colors.text.warning,
onPrimary = colors.text.primary1,
onSecondary = colors.text.primary1,
@ -94,10 +94,10 @@ private fun lightThemeColors(): TangemColors {
attention = TangemColorPalette.Tangerine,
),
icon = TangemColors.Icon(
primary1 = TangemColorPalette.Black,
primary1 = TangemColorPalette.Dark6,
primary2 = TangemColorPalette.White,
secondary = TangemColorPalette.Dark2,
informative = TangemColorPalette.Light5,
informative = TangemColorPalette.Dark1,
inactive = TangemColorPalette.Light4,
warning = TangemColorPalette.Amaranth,
attention = TangemColorPalette.Tangerine,
@ -106,15 +106,12 @@ private fun lightThemeColors(): TangemColors {
primary = TangemColorPalette.Dark6,
secondary = TangemColorPalette.Light2,
disabled = TangemColorPalette.Light2,
positiveDisabled = TangemColorPalette.MagicMint,
),
background = TangemColors.Background(
primary = TangemColorPalette.White,
secondary = TangemColorPalette.Light1,
tertiary = TangemColorPalette.Light1,
plain = TangemColorPalette.White,
action = TangemColorPalette.White,
fade = TangemColorPalette.White,
),
control = TangemColors.Control(
checked = TangemColorPalette.Dark6,
@ -123,7 +120,7 @@ private fun lightThemeColors(): TangemColors {
),
stroke = TangemColors.Stroke(
primary = TangemColorPalette.Light2,
secondary = TangemColorPalette.Dark4,
secondary = TangemColorPalette.Dark5,
transparency = TangemColorPalette.White,
),
field = TangemColors.Field(
@ -149,25 +146,22 @@ private fun darkThemeColors(): TangemColors {
icon = TangemColors.Icon(
primary1 = TangemColorPalette.White,
primary2 = TangemColorPalette.Dark6,
secondary = TangemColorPalette.Dark1,
informative = TangemColorPalette.Dark2,
inactive = TangemColorPalette.Dark4,
secondary = TangemColorPalette.Light5,
informative = TangemColorPalette.Dark1,
inactive = TangemColorPalette.Dark3,
warning = TangemColorPalette.Flamingo,
attention = TangemColorPalette.Mustard,
),
button = TangemColors.Button(
primary = TangemColorPalette.Light4,
primary = TangemColorPalette.Light2,
secondary = TangemColorPalette.Dark4,
disabled = TangemColorPalette.Dark5,
positiveDisabled = TangemColorPalette.DarkGreen,
),
background = TangemColors.Background(
primary = TangemColorPalette.Dark6,
secondary = TangemColorPalette.Black,
tertiary = TangemColorPalette.Dark6,
plain = TangemColorPalette.Black,
action = TangemColorPalette.Dark5,
fade = TangemColorPalette.Black,
),
control = TangemColors.Control(
checked = TangemColorPalette.Azure,
@ -176,7 +170,7 @@ private fun darkThemeColors(): TangemColors {
),
stroke = TangemColors.Stroke(
primary = TangemColorPalette.Dark4,
secondary = TangemColorPalette.Dark1,
secondary = TangemColorPalette.Dark4,
transparency = TangemColorPalette.Dark6,
),
field = TangemColors.Field(

View file

@ -57,7 +57,7 @@ data class TangemTypography internal constructor(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
letterSpacing = TextUnit(value = 0.5f, type = TextUnitType.Sp),
lineHeight = TextUnit(value = 24f, type = TextUnitType.Sp),
lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp),
),
val body1: TextStyle = TextStyle(
fontFamily = RobotoFamily,

View file

@ -45,7 +45,7 @@ abstract class ComposeBottomSheetFragment : BottomSheetDialogFragment(), Compose
if (expandedHeightFraction != null) it.fillMaxHeight(expandedHeightFraction!!) else it
}
.background(
color = TangemTheme.colors.background.plain,
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.bottomSheet,
)

View file

@ -10,6 +10,7 @@ import java.util.Locale
object BigDecimalFormatter {
const val EMPTY_BALANCE_SIGN = ""
const val CAN_BE_LOWER_SIGN = "<"
private const val TEMP_CURRENCY_CODE = "USD"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,13 @@
package com.tangem.data.transaction
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.transaction.FeeRepository
internal class DefaultFeeRepository : FeeRepository {
override fun isFeeApproximate(networkId: Network.ID, amountType: AmountType): Boolean {
val blockchain = Blockchain.fromId(networkId.value)
return blockchain.isFeeApproximate(amountType)
}
}

View file

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

View file

@ -1,6 +1,8 @@
package com.tangem.data.transaction.di
import com.tangem.data.transaction.DefaultFeeRepository
import com.tangem.data.transaction.DefaultTransactionRepository
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -25,4 +27,10 @@ internal object TransactionDataModule {
coroutineDispatcherProvider = coroutineDispatcherProvider,
)
}
@Provides
@Singleton
fun providesFeeRepository(): FeeRepository {
return DefaultFeeRepository()
}
}

1
data/visa/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,21 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.visa"
}
dependencies {
/** Project - Domain */
implementation(projects.domain.visa)
implementation(projects.domain.wallets.models)
/** DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,12 @@
package com.tangem.data.visa
import com.tangem.domain.visa.model.VisaCurrency
import com.tangem.domain.visa.repository.VisaRepository
import com.tangem.domain.wallets.models.UserWalletId
internal class DummyVisaRepository : VisaRepository {
override suspend fun getVisaCurrency(userWalletId: UserWalletId, isRefresh: Boolean): VisaCurrency {
TODO(reason = "Implement in [REDACTED_JIRA]")
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.visa.di
import com.tangem.data.visa.DummyVisaRepository
import com.tangem.domain.visa.repository.VisaRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object VisaDataModule {
@Provides
@Singleton
fun provideVisaRepository(): VisaRepository {
return DummyVisaRepository()
}
}

View file

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

View file

@ -42,6 +42,12 @@ internal class TangemCardTypesResolver(
override fun isBadWallet(): Boolean = card.batchId == BAD_WALLET_BATCH_ID
override fun isJrWallet(): Boolean = card.batchId == JR_WALLET_BATCH_ID
override fun isGrimWallet(): Boolean = card.batchId == GRIM_WALLET_BATCH_ID
override fun isSatoshiFriendsWallet(): Boolean = card.batchId == SATOSHI_WALLET_BATCH_ID
override fun isWhiteWallet(): Boolean {
return walletData == null && card.firmwareVersion <= FirmwareVersion.HDWalletAvailable
}
@ -66,7 +72,10 @@ internal class TangemCardTypesResolver(
override fun isSingleWalletWithToken(): Boolean = walletData?.token != null && !isMultiwalletAllowed()
override fun isMultiwalletAllowed(): Boolean {
return !isTangemTwins() && !card.isStart2Coin && !isTangemNote() &&
return !isTangemTwins() &&
!card.isStart2Coin &&
!isTangemNote() &&
!isVisaWallet() &&
(multiWalletAvailable() || card.wallets.firstOrNull()?.curve == EllipticCurve.Secp256k1)
}
@ -75,6 +84,7 @@ internal class TangemCardTypesResolver(
override fun getBlockchain(): Blockchain {
return when (productType) {
ProductType.Start2Coin -> if (card.isTestCard) Blockchain.BitcoinTestnet else Blockchain.Bitcoin
ProductType.Visa -> Blockchain.PolygonTestnet
else -> {
val blockchainName: String = walletData?.blockchain
?: if (productType == ProductType.Note) {
@ -138,6 +148,9 @@ internal class TangemCardTypesResolver(
const val TRON_WALLET_BATCH_ID = "AF07"
const val KASPA_WALLET_BATCH_ID = "AF08"
const val BAD_WALLET_BATCH_ID = "AF09"
const val JR_WALLET_BATCH_ID = "AF14"
const val GRIM_WALLET_BATCH_ID = "AF13"
const val SATOSHI_WALLET_BATCH_ID = "AF19"
const val WHITE_WALLET2_BATCH_ID = "AF15"
const val TRILLIANT_WALLET_BATCH_ID = "AF16"
const val AVRORA_WALLET_BATCH_ID = "AF18"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,10 @@
package com.tangem.domain.transaction
import com.tangem.blockchain.common.AmountType
import com.tangem.domain.tokens.model.Network
interface FeeRepository {
/** Returns if fee is approximate for current [networkId] */
fun isFeeApproximate(networkId: Network.ID, amountType: AmountType): Boolean
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
package com.tangem.domain.transaction.usecase
import com.tangem.blockchain.common.AmountType
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.transaction.FeeRepository
/**
* Use case to check if fee is approximate
*
* @param feeRepository [FeeRepository]
*/
class IsFeeApproximateUseCase(
private val feeRepository: FeeRepository,
) {
operator fun invoke(networkId: Network.ID, amountType: AmountType): Boolean {
return feeRepository.isFeeApproximate(networkId, amountType)
}
}

View file

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

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