Updated on 2026-08-14

This commit is contained in:
Tangem 2020-09-14 10:09:01 +00:00
commit 8301a40879
39 changed files with 1855 additions and 663 deletions

View file

@ -52,4 +52,19 @@ fun BigDecimal.toFiatString(rateValue: BigDecimal): String? {
return "USD $fiatValue"
}
fun BigDecimal.stripZeroPlainString(): String = this.stripTrailingZeros().toPlainString()
fun BigDecimal.stripZeroPlainString(): String = this.stripTrailingZeros().toPlainString()
fun BigDecimal.isPositive(): Boolean = this.compareTo(BigDecimal.ZERO) == 1
fun BigDecimal.isNegative(): Boolean = this.compareTo(BigDecimal.ZERO) == -1
fun BigDecimal.isGreaterThan(value: BigDecimal): Boolean = this.compareTo(value) == 1
fun BigDecimal.isLessThan(value: BigDecimal): Boolean = this.compareTo(value) == -1
fun BigDecimal.isGreaterThanOrEqual(value: BigDecimal): Boolean {
val compareResult = this.compareTo(value)
return compareResult == 1 || compareResult == 0
}
fun BigDecimal.isLessThanOrEqual(value: BigDecimal): Boolean {
val compareResult = this.compareTo(value)
return compareResult == -1 || compareResult == 0
}

View file

@ -27,10 +27,20 @@ fun EditText.update(text: String?) {
else setSelection(cursorPosition)
}
fun TextSwitcher.update(text: String?) {
fun EditText.setOnImeActionListener(action: Int, handler: (EditText) -> Unit) {
this.setOnEditorActionListener { view, actionId, event ->
if (actionId == action) {
handler.invoke(this)
return@setOnEditorActionListener true
}
return@setOnEditorActionListener false
}
}
fun TextSwitcher.update(text: CharSequence?) {
val textView = this.currentView as? TextView ?: return
if (textView.text?.toString() != text) this.setText(text)
if (textView.text?.toString() != text?.toString()) this.setText(text)
}
// By default the TextInputLayout didn't activates the error state if the message is empty or null

View file

@ -14,6 +14,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.text.toSpannable
import androidx.fragment.app.Fragment
@ -27,17 +28,26 @@ fun Context.getDrawableCompat(@DrawableRes drawableResId: Int): Drawable? {
return ContextCompat.getDrawable(this, drawableResId)
}
fun View.show(show: Boolean) {
if (show) this.visibility = View.VISIBLE else this.visibility = View.GONE
fun View.getString(@StringRes id: Int): String {
return context.getString(id)
}
fun View.show() {
fun View.show(show: Boolean, invokeBeforeStateChanged: (() -> Unit)? = null) {
return if (show) this.show(invokeBeforeStateChanged)
else this.hide(invokeBeforeStateChanged)
}
fun View.show(invokeBeforeStateChanged: (() -> Unit)? = null) {
if (this.visibility == View.VISIBLE) return
invokeBeforeStateChanged?.invoke()
this.visibility = View.VISIBLE
}
fun View.hide() {
fun View.hide(invokeBeforeStateChanged: (() -> Unit)? = null) {
if (this.visibility == View.GONE) return
invokeBeforeStateChanged?.invoke()
this.visibility = View.GONE
}

View file

@ -2,9 +2,11 @@ package com.tangem.tap.common.extensions
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewParent
import androidx.transition.AutoTransition
import androidx.transition.Transition
import androidx.transition.TransitionManager
import timber.log.Timber
/**
[REDACTED_AUTHOR]
@ -16,6 +18,11 @@ fun ViewGroup.inflate(viewToInflate: Int, rootView: ViewGroup?, parent: ViewGrou
}
}
fun ViewParent?.beginDelayedTransition(transition: Transition = AutoTransition()) {
if (this == null) Timber.e("Can't invoke beginDelayedTransition, because parent is NULL")
(this as? ViewGroup)?.beginDelayedTransition(transition)
}
fun ViewGroup.beginDelayedTransition(transition: Transition = AutoTransition()) {
TransitionManager.beginDelayedTransition(this, transition)
}

View file

@ -2,7 +2,7 @@ package com.tangem.tap.common.redux
import com.tangem.tap.common.redux.global.globalReducer
import com.tangem.tap.common.redux.navigation.NavigationReducer
import com.tangem.tap.features.send.redux.SendReducer
import com.tangem.tap.features.send.redux.reducers.SendReducer
import com.tangem.tap.features.wallet.redux.WalletReducer
import org.rekotlin.Action

View file

@ -4,8 +4,8 @@ import com.tangem.tap.common.redux.global.GlobalState
import com.tangem.tap.common.redux.navigation.NavigationState
import com.tangem.tap.common.redux.navigation.navigationMiddleware
import com.tangem.tap.features.home.redux.homeMiddleware
import com.tangem.tap.features.send.redux.SendState
import com.tangem.tap.features.send.redux.sendMiddleware
import com.tangem.tap.features.send.redux.middlewares.sendMiddleware
import com.tangem.tap.features.send.redux.states.SendState
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.features.wallet.redux.walletMiddleware
import org.rekotlin.Middleware

View file

@ -2,6 +2,8 @@ package com.tangem.tap.domain
import androidx.activity.ComponentActivity
import com.tangem.*
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.WalletManager
import com.tangem.commands.CommandResponse
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.CardType
@ -9,6 +11,7 @@ import com.tangem.tangem_sdk_new.extensions.init
import com.tangem.tap.domain.tasks.CreateWalletAndRescanTask
import com.tangem.tap.domain.tasks.ScanNoteResponse
import com.tangem.tap.domain.tasks.ScanNoteTask
import com.tangem.tap.domain.tasks.SendTask
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
@ -28,6 +31,21 @@ class TangemSdkManager(val activity: ComponentActivity) {
return runTaskAsyncReturnOnMain(CreateWalletAndRescanTask())
}
suspend fun send(
walletManager: WalletManager,
recipientAddress: String,
amountToSend: Amount,
feeAmount: Amount
): CompletionResult<CommandResponse> {
return withContext(Dispatchers.IO) {
suspendCoroutine { continuation ->
tangemSdk.startSessionWithRunnable(SendTask(walletManager, recipientAddress, amountToSend, feeAmount)) {
continuation.resume(it)
}
}
}
}
private suspend fun <T : CommandResponse> runTaskAsync(
runnable: CardSessionRunnable<T>, cardId: String? = null, initialMessage: Message? = null
): CompletionResult<T> =

View file

@ -9,4 +9,6 @@ sealed class TapError(@StringRes val localizedMessage: Int): Throwable() {
object PayIdEmptyField: TapError(R.string.wallet_create_payid_empty)
object UnknownBlockchain: TapError(R.string.wallet_unknown_blockchain)
object NoInternetConnection: TapError(R.string.notification_no_internet)
object InsufficientBalance: TapError(R.string.error_insufficient_balance)
object BlockchainInternalError: TapError(R.string.error_insufficient_balance)
}

View file

@ -0,0 +1,114 @@
package com.tangem.tap.domain.tasks
import com.tangem.CardSession
import com.tangem.CardSessionRunnable
import com.tangem.TangemError
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionSender
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.commands.CommandResponse
import com.tangem.commands.SignCommand
import com.tangem.commands.SignResponse
import com.tangem.common.CompletionResult
import com.tangem.tap.scope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import kotlin.coroutines.resume
class SendTask(
private val walletManager: WalletManager,
private val recipientAddress: String,
private val amountToSend: Amount,
private val feeAmount: Amount,
) : CardSessionRunnable<CommandResponse> {
override val requiresPin2: Boolean = false
override fun run(session: CardSession, callback: (result: CompletionResult<CommandResponse>) -> Unit) {
val txSender = walletManager as TransactionSender
val verifyResult = walletManager.validateTransaction(amountToSend, feeAmount)
if (verifyResult.isNotEmpty()) {
callback(CompletionResult.Failure(InsufficientBalance()))
return
}
val txData = walletManager.createTransaction(amountToSend, feeAmount, recipientAddress)
scope.launch {
when (val result = txSender.send(txData, SessionTransactionSigner(session))) {
is SimpleResult.Success -> callback(CompletionResult.Success(SendResponse()))
is SimpleResult.Failure -> {
callback(CompletionResult.Failure(BlockchainInternalErrorConverter.convert(result.error)))
}
}
}
}
}
class SendResponse : CommandResponse
class SessionTransactionSigner(
private val session: CardSession
) : TransactionSigner {
override suspend fun sign(hashes: Array<ByteArray>, cardId: String): CompletionResult<SignResponse> =
suspendCancellableCoroutine { continuation ->
Timber.d("sign transaction...")
SignCommand(hashes).run(session) {
if (continuation.isActive) {
continuation.resume(it)
}
}
}
}
abstract class SendError : TangemError {
}
class UnknownError : SendError() {
override val code: Int = 1000
override var customMessage: String = "Unknown error"
}
open class ThrowableError(throwable: Throwable?) : SendError() {
override val code: Int = 1001
override var customMessage: String = throwable?.localizedMessage ?: "Unknown exception"
}
class InsufficientBalance(
override var customMessage: String = "Insufficient balance"
) : SendError() {
override val code: Int = 1021
}
class BlockchainInternalError(
override var customMessage: String
) : SendError() {
override val code: Int = 2000
}
class BlockchainInternalErrorConverter {
companion object {
private val stellarInternalErrors = mapOf(
"tx_bad_seq" to "Sequence number does not match source account",
"tx_too_late" to "The ledger closeTime was after the maxTime",
"tx_failedop_no_destination" to "The destination account does not exist",
"tx_no_source_account" to "Source account not found"
)
fun convert(throwable: Throwable?): TangemError {
val message = throwable?.message ?: return ThrowableError(throwable)
val customMessage = getInternalBlockchainErrorMessage(message)
return if (customMessage == null) ThrowableError(throwable)
else BlockchainInternalError(customMessage)
}
private fun getInternalBlockchainErrorMessage(message: String): String? {
return stellarInternalErrors[message]
}
}
}

View file

@ -1,11 +1,15 @@
package com.tangem.tap.features.send
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.android.synthetic.main.fragment_wallet.*
import org.rekotlin.StoreSubscriber
@ -16,6 +20,7 @@ abstract class BaseStoreFragment(layoutId: Int) : Fragment(layoutId) {
abstract fun subscribeToStore()
private lateinit var mainView: View
protected val storeSubscribersList = mutableListOf<StoreSubscriber<*>>()
override fun onCreate(savedInstanceState: Bundle?) {
@ -27,6 +32,11 @@ abstract class BaseStoreFragment(layoutId: Int) : Fragment(layoutId) {
})
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
mainView = super.onCreateView(inflater, container, savedInstanceState)!!
return mainView
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
toolbar.setNavigationOnClickListener { store.dispatch(NavigationAction.PopBackTo()) }
@ -42,4 +52,12 @@ abstract class BaseStoreFragment(layoutId: Int) : Fragment(layoutId) {
storeSubscribersList.forEach { store.unsubscribe(it) }
super.onStop()
}
fun showRetrySnackbar(message: String, action: () -> Unit) {
val snackbar = Snackbar.make(mainView, message, Snackbar.LENGTH_INDEFINITE)
snackbar.setAction(getString(R.string.generic_retry)) {
snackbar.dismiss()
action()
}.show()
}
}

View file

@ -1,130 +0,0 @@
package com.tangem.tap.features.send.redux
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManager
import com.tangem.commands.common.network.Result
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.PayIdManager
import com.tangem.tap.domain.PayIdManager.Companion.isPayId
import com.tangem.tap.domain.isPayIdSupported
import com.tangem.tap.features.send.redux.AddressPayIdActionUi.ChangeAddressOrPayId
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.*
import com.tangem.tap.features.send.redux.AmountActionUi.SetMainCurrency
import com.tangem.tap.features.send.redux.AmountActionUi.ToggleMainCurrency
import com.tangem.tap.scope
import com.tangem.tap.store
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.rekotlin.Action
import org.rekotlin.Middleware
/**
[REDACTED_AUTHOR]
*/
val sendMiddleware: Middleware<AppState> = { dispatch, appState ->
{ nextDispatch ->
{ action ->
handleSendAction(action)
nextDispatch(action)
}
}
}
private fun handleSendAction(action: Action) {
val sendAction = action as? SendScreenActionUi ?: return
when (sendAction) {
is AddressPayIdActionUi -> {
when (sendAction) {
is ChangeAddressOrPayId -> AddressPayIdHandler().handle(sendAction.data)
}
}
is AmountActionUi -> {
when (sendAction) {
is ToggleMainCurrency -> {
if (store.state.sendState.amountState.mainCurrency.value == MainCurrencyType.FIAT) {
store.dispatch(SetMainCurrency(MainCurrencyType.CRYPTO))
} else {
store.dispatch(SetMainCurrency(MainCurrencyType.FIAT))
}
}
}
}
}
}
internal class AddressPayIdHandler {
fun handle(data: String) {
val walletManager = store.state.globalState.scanNoteResponse?.walletManager ?: return
if (data == store.state.sendState.addressPayIdState.etFieldValue) return
if (isPayId(data)) {
verifyPayId(data, walletManager)
} else {
val supposedAddress = extractAddressFromShareUri(data)
store.dispatch(PayIdVerification.SetError(supposedAddress, FailReason.IS_NOT_PAY_ID))
verifyAddress(walletManager, supposedAddress)
}
}
private fun verifyPayId(payId: String, walletManager: WalletManager) {
val blockchain = walletManager.wallet.blockchain
if (!blockchain.isPayIdSupported()) {
store.dispatch(PayIdVerification.SetError(payId, FailReason.PAY_ID_UNSUPPORTED_BY_BLOCKCHAIN))
return
}
scope.launch {
val result = PayIdManager().verifyPayId(payId, blockchain)
withContext(Dispatchers.Main) {
when (result) {
is Result.Success -> {
val address = result.data.getAddress()
if (address == null) {
store.dispatch(PayIdVerification.SetError(payId, FailReason.PAY_ID_NOT_REGISTERED))
return@withContext
}
val failReason = isValidBlockchainAddressAndNotTheSameAsWallet(walletManager.wallet, address)
val actionToSend = if (failReason == FailReason.NONE) PayIdVerification.SetPayIdWalletAddress(payId, address)
else AddressVerification.SetError(payId, failReason)
store.dispatch(actionToSend)
}
is Result.Failure -> {
store.dispatch(PayIdVerification.SetError(payId, FailReason.PAY_ID_REQUEST_FAILED))
}
}
}
}
}
private fun verifyAddress(walletManager: WalletManager, supposedAddress: String) {
val failReason = isValidBlockchainAddressAndNotTheSameAsWallet(walletManager.wallet, supposedAddress)
val actionToSend = if (failReason == FailReason.NONE) AddressVerification.SetWalletAddress(supposedAddress)
else AddressVerification.SetError(supposedAddress, failReason)
store.dispatch(actionToSend)
}
private fun isValidBlockchainAddressAndNotTheSameAsWallet(wallet: Wallet, address: String): FailReason {
return if (wallet.blockchain.validateAddress(address)) {
if (wallet.address != address) {
FailReason.NONE
} else {
FailReason.ADDRESS_SAME_AS_WALLET
}
} else {
FailReason.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN
}
}
//TODO: move to the blockchainSDK
private fun extractAddressFromShareUri(shareUri: String): String {
val sharePrefix = listOf("bitcoin:", "ethereum:", "ripple:")
val prefixes = sharePrefix.filter { shareUri.contains(it) }
return if (prefixes.isEmpty()) shareUri
else shareUri.replace(prefixes[0], "")
}
}

View file

@ -1,186 +0,0 @@
package com.tangem.tap.features.send.redux
import android.view.View
import com.tangem.blockchain.common.AmountType
import com.tangem.common.extensions.isZero
import com.tangem.tap.common.CurrencyConverter
import com.tangem.tap.common.entities.TapCurrency
import com.tangem.tap.common.extensions.stripZeroPlainString
import com.tangem.tap.features.send.redux.AddressPayIdActionUi.*
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.AddressVerification
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.PayIdVerification
import com.tangem.tap.features.send.redux.AmountActionUi.ChangeAmountToSend
import com.tangem.tap.features.send.redux.AmountActionUi.SetMainCurrency
import com.tangem.tap.features.send.redux.FeeActionUi.*
import com.tangem.tap.store
import org.rekotlin.Action
import org.rekotlin.StateType
import timber.log.Timber
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
class SendReducer {
companion object {
fun reduce(action: Action, sendState: SendState): SendState {
val newState = internalReduce(action, sendState)
if (newState == sendState) Timber.i("state didn't modified.")
else Timber.i("state was updated to: $newState.")
return newState
}
}
}
private fun internalReduce(incomingAction: Action, sendState: SendState): SendState {
if (incomingAction is ReleaseSendState) return SendState()
val action = incomingAction as? SendScreenAction ?: return sendState
var state = when (action) {
is AddressPayIdActionUi -> handleAddressPayIdActionUi(action, sendState, sendState.addressPayIdState)
is AddressPayIdVerifyAction -> handleAddressPayIdAction(action, sendState, sendState.addressPayIdState)
is AmountActionUi -> handleAmountActionUi(action, sendState, sendState.amountState)
is FeeActionUi -> handleFeeActionUi(action, sendState, sendState.feeLayoutState)
else -> sendState
}
state = state.copy(sendButtonIsEnabled = state.addressPayIdState.error == null
&& state.addressPayIdState.walletAddress?.isNotEmpty() ?: false
&& !state.amountState.amountIsOverBalance
&& !state.amountState.amountToSendCrypto.isZero()
)
return state
}
fun handleAddressPayIdActionUi(
action: AddressPayIdActionUi,
sendState: SendState,
state: AddressPayIdState
): SendState {
val result = when (action) {
is ChangeAddressOrPayId -> state
is SetTruncateHandler -> state.copy(truncateHandler = action.handler)
is TruncateOrRestore -> {
if (action.truncate) state.copy(etFieldValue = state.truncatedFieldValue)
else state.copy(etFieldValue = state.normalFieldValue)
}
}
return updateLastState(sendState.copy(addressPayIdState = result), result)
}
private fun handleAddressPayIdAction(
action: AddressPayIdVerifyAction,
sendState: SendState,
state: AddressPayIdState
): SendState {
val result = when (action) {
is PayIdVerification.SetPayIdWalletAddress -> state.copyPayIdWalletAddress(action.payId, action.payIdWalletAddress)
is PayIdVerification.SetError -> state.copyPaiIdError(action.payId, action.reason)
is AddressVerification.SetWalletAddress -> state.copyWalletAddress(action.address)
is AddressVerification.SetError -> state.copyError(action.address, action.reason)
}
return updateLastState(sendState.copy(addressPayIdState = result), result)
}
private fun handleAmountActionUi(action: AmountActionUi, sendState: SendState, state: AmountState): SendState {
val rates = store.state.globalState.fiatRates
val wallet = store.state.walletState.wallet ?: return sendState
val fiatRate = rates.getRateForCryptoCurrency(wallet.blockchain.currency) ?: return sendState
val walletAmount = wallet.amounts[AmountType.Token] ?: wallet.amounts[AmountType.Coin]
val converter = CurrencyConverter(fiatRate)
val result = when (action) {
is SetMainCurrency -> {
when (action.mainCurrency) {
MainCurrencyType.FIAT -> {
val fiatToSend = if (state.amountToSendCrypto.isZero()) BigDecimal.ZERO
else converter.toFiat(state.amountToSendCrypto)
val fiatBalance = converter.toFiat(walletAmount?.value ?: BigDecimal.ZERO)
state.copy(
etAmountFieldValue = fiatToSend.stripZeroPlainString(),
balance = fiatBalance,
mainCurrency = Value(MainCurrencyType.FIAT, TapCurrency.main),
cursorAtTheSamePosition = false
)
}
MainCurrencyType.CRYPTO -> {
val cryptoBalance = walletAmount?.value ?: BigDecimal.ZERO
state.copy(
etAmountFieldValue = state.amountToSendCrypto.stripZeroPlainString(),
amountToSendCrypto = state.amountToSendCrypto,
balance = cryptoBalance.stripTrailingZeros(),
mainCurrency = Value(MainCurrencyType.CRYPTO, walletAmount?.currencySymbol ?: "null"),
cursorAtTheSamePosition = false
)
}
}
}
is AmountActionUi.SetMaxAmount -> {
when (state.mainCurrency.value) {
MainCurrencyType.FIAT -> {
val cryptoBalance = walletAmount?.value ?: BigDecimal.ZERO
val fiatToSend = converter.toFiat(cryptoBalance)
state.copy(
etAmountFieldValue = fiatToSend.stripZeroPlainString(),
amountToSendCrypto = cryptoBalance,
cursorAtTheSamePosition = false
)
}
MainCurrencyType.CRYPTO -> {
val cryptoBalance = walletAmount?.value ?: BigDecimal.ZERO
state.copy(
etAmountFieldValue = cryptoBalance.stripZeroPlainString(),
amountToSendCrypto = cryptoBalance,
cursorAtTheSamePosition = false
)
}
}
}
is ChangeAmountToSend -> {
val bdData = when {
action.data.isEmpty() || action.data == "0" -> BigDecimal.ZERO
else -> BigDecimal(action.data)
}
when (state.mainCurrency.value) {
MainCurrencyType.FIAT -> {
val sendCrypto = converter.toCrypto(bdData, wallet.blockchain.decimals()).stripTrailingZeros()
state.copy(
etAmountFieldValue = action.data,
amountToSendCrypto = sendCrypto,
amountIsOverBalance = state.balance < bdData,
cursorAtTheSamePosition = true
)
}
MainCurrencyType.CRYPTO -> {
state.copy(
etAmountFieldValue = action.data,
amountToSendCrypto = bdData,
amountIsOverBalance = state.balance < bdData,
cursorAtTheSamePosition = true
)
}
}
}
else -> state
}
return updateLastState(sendState.copy(amountState = result), result)
}
private fun handleFeeActionUi(action: FeeActionUi, sendState: SendState, state: FeeLayoutState): SendState {
val result = when (action) {
is ToggleFeeLayoutVisibility -> {
state.copy(visibility = if (state.visibility == View.VISIBLE) View.GONE else View.VISIBLE)
}
is ChangeSelectedFee -> state.copy(selectedFeeId = action.id)
is ChangeIncludeFee -> state.copy(includeFeeIsChecked = action.isChecked)
}
return updateLastState(sendState.copy(feeLayoutState = result), result)
}
private fun updateLastState(sendState: SendState, lastChangedState: StateType): SendState =
sendState.copy(lastChangedStateType = lastChangedState)

View file

@ -1,7 +1,12 @@
package com.tangem.tap.features.send.redux
import com.tangem.blockchain.common.Amount
import com.tangem.tap.common.redux.ErrorAction
import com.tangem.tap.domain.TapError
import com.tangem.tap.features.send.redux.states.FeeType
import com.tangem.tap.features.send.redux.states.MainCurrencyType
import org.rekotlin.Action
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
@ -12,7 +17,7 @@ interface SendScreenActionUi : SendScreenAction
object ReleaseSendState : Action
data class PrepareSendScreen(
val amount: Amount
val amount: Amount,
) : SendScreenAction
// Address or PayId
@ -23,8 +28,7 @@ sealed class AddressPayIdActionUi : SendScreenActionUi {
}
sealed class AddressPayIdVerifyAction : SendScreenAction {
enum class FailReason {
NONE,
enum class Error {
IS_NOT_PAY_ID,
PAY_ID_UNSUPPORTED_BY_BLOCKCHAIN,
PAY_ID_NOT_REGISTERED,
@ -34,12 +38,12 @@ sealed class AddressPayIdVerifyAction : SendScreenAction {
}
sealed class PayIdVerification : AddressPayIdVerifyAction() {
data class SetError(val payId: String, val reason: FailReason) : PayIdVerification()
data class SetError(val payId: String, val error: Error) : PayIdVerification()
data class SetPayIdWalletAddress(val payId: String, val payIdWalletAddress: String) : PayIdVerification()
}
sealed class AddressVerification : AddressPayIdVerifyAction() {
data class SetError(val address: String, val reason: FailReason) : AddressVerification()
data class SetError(val address: String, val error: Error) : AddressVerification()
data class SetWalletAddress(val address: String) : AddressVerification()
}
}
@ -47,14 +51,68 @@ sealed class AddressPayIdVerifyAction : SendScreenAction {
// Amount to send
sealed class AmountActionUi : SendScreenActionUi {
object SetMaxAmount : AmountActionUi()
data class ChangeAmountToSend(val data: String) : AmountActionUi()
data class CheckAmountToSend(val data: String? = null) : AmountActionUi()
data class SetMainCurrency(val mainCurrency: MainCurrencyType) : AmountActionUi()
object ToggleMainCurrency : AmountActionUi()
}
sealed class AmountAction : SendScreenAction {
enum class Error {
FEE_GREATER_THAN_AMOUNT,
AMOUNT_WITH_FEE_GREATER_THAN_BALANCE
}
sealed class AmountVerification : AmountAction() {
data class SetAmount(val amount: BigDecimal) : AmountVerification()
data class SetError(val amount: BigDecimal, val error: Error) : AmountVerification()
}
}
// Fee
sealed class FeeActionUi : SendScreenActionUi {
object ToggleFeeLayoutVisibility : FeeActionUi()
data class ChangeSelectedFee(val id: Int) : FeeActionUi()
class ChangeIncludeFee(val isChecked: Boolean) : FeeActionUi()
object ToggleControlsVisibility : FeeActionUi()
data class ChangeSelectedFee(val feeType: FeeType) : FeeActionUi()
class ChangeIncludeFee(val isIncluded: Boolean) : FeeActionUi()
}
sealed class FeeAction : SendScreenAction {
enum class Error {
ADDRESS_OR_AMOUNT_IS_EMPTY,
REQUEST_FAILED
}
object RequestFee : FeeAction()
sealed class FeeCalculation : FeeAction() {
data class SetFeeResult(val fee: List<Amount>) : FeeCalculation()
data class SetFeeError(val error: Error) : FeeCalculation()
}
data class ChangeLayoutVisibility(
val main: Boolean? = null,
val controls: Boolean? = null,
val chipGroup: Boolean? = null,
) : FeeAction()
}
sealed class ReceiptAction : SendScreenAction {
object RefreshReceipt : ReceiptAction()
}
sealed class SendActionUi : SendScreenActionUi {
object SendAmountToRecipient : SendScreenActionUi
}
sealed class SendAction : SendScreenAction {
enum class Error {
INSUFFICIENT_BALANCE, BLOCKCHAIN_INTERNAL
}
object SendSuccess : SendAction()
data class SendError(val sendError: Error) : SendAction(), ErrorAction {
override val error: TapError = when (sendError) {
Error.INSUFFICIENT_BALANCE -> TapError.InsufficientBalance
Error.BLOCKCHAIN_INTERNAL -> TapError.BlockchainInternalError
}
}
}

View file

@ -1,100 +0,0 @@
package com.tangem.tap.features.send.redux
import android.view.View
import com.tangem.tap.common.entities.TapCurrency
import com.tangem.wallet.R
import org.rekotlin.StateType
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
data class SendState(
val lastChangedStateType: StateType = NoneState(),
val addressPayIdState: AddressPayIdState = AddressPayIdState(),
val amountState: AmountState = AmountState(),
val feeLayoutState: FeeLayoutState = FeeLayoutState(),
val sendButtonIsEnabled: Boolean = false
) : StateType
class NoneState : StateType
data class AddressPayIdState(
val etFieldValue: String? = null,
val normalFieldValue: String? = null,
val truncatedFieldValue: String? = null,
val walletAddress: String? = null,
val error: AddressPayIdVerifyAction.FailReason? = null,
val truncateHandler: ((String) -> String)? = null
) : StateType {
fun isPayIdState(): Boolean = walletAddress != null && walletAddress != normalFieldValue
fun copyWalletAddress(address: String): AddressPayIdState {
val truncated = truncateHandler?.invoke(address) ?: address
return this.copy(
etFieldValue = address,
normalFieldValue = address,
truncatedFieldValue = truncated,
walletAddress = address,
error = null
)
}
fun copyError(address: String, error: AddressPayIdVerifyAction.FailReason): AddressPayIdState {
val truncated = truncateHandler?.invoke(address) ?: address
return this.copy(
etFieldValue = address,
normalFieldValue = address,
truncatedFieldValue = truncated,
error = error,
walletAddress = null
)
}
fun copyPayIdWalletAddress(payId: String, address: String): AddressPayIdState {
val truncated = truncateHandler?.invoke(address) ?: address
return this.copy(
etFieldValue = payId,
normalFieldValue = payId,
truncatedFieldValue = truncated,
walletAddress = address,
error = null
)
}
fun copyPaiIdError(payId: String, error: AddressPayIdVerifyAction.FailReason): AddressPayIdState {
val truncated = truncateHandler?.invoke(payId) ?: payId
return this.copy(
etFieldValue = payId,
normalFieldValue = payId,
truncatedFieldValue = truncated,
error = error,
walletAddress = null
)
}
}
enum class MainCurrencyType {
FIAT, CRYPTO
}
data class Value<T>(
val value: T,
val displayedValue: String
)
data class AmountState(
val etAmountFieldValue: String = BigDecimal.ZERO.toPlainString(),
val cursorAtTheSamePosition: Boolean = true,
val amountToSendCrypto: BigDecimal = BigDecimal.ZERO,
val balance: BigDecimal = BigDecimal.ZERO,
val mainCurrency: Value<MainCurrencyType> = Value(MainCurrencyType.FIAT, TapCurrency.main),
val amountIsOverBalance: Boolean = false
) : StateType
data class FeeLayoutState(
val visibility: Int = View.GONE,
val selectedFeeId: Int = R.id.chipNormal,
val includeFeeIsChecked: Boolean = false
) : StateType

View file

@ -0,0 +1,93 @@
package com.tangem.tap.features.send.redux.middlewares
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManager
import com.tangem.commands.common.network.Result
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.PayIdManager
import com.tangem.tap.domain.isPayIdSupported
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.*
import com.tangem.tap.scope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.rekotlin.DispatchFunction
/**
[REDACTED_AUTHOR]
*/
internal class AddressPayIdMiddleware {
fun handle(data: String, appState: AppState?, dispatch: DispatchFunction) {
val sendState = appState?.sendState ?: return
val walletManager = sendState.walletManager ?: return
if (data == sendState.addressPayIdState.etFieldValue) return
if (PayIdManager.isPayId(data)) {
verifyPayId(data, walletManager, dispatch)
} else {
val supposedAddress = extractAddressFromShareUri(data)
dispatch(PayIdVerification.SetError(supposedAddress, Error.IS_NOT_PAY_ID))
val failReason = isValidBlockchainAddressAndNotTheSameAsWallet(walletManager.wallet, supposedAddress)
if (failReason == null) {
dispatch(AddressVerification.SetWalletAddress(supposedAddress))
} else {
dispatch(AddressVerification.SetError(supposedAddress, failReason))
}
}
}
private fun verifyPayId(payId: String, walletManager: WalletManager, dispatch: DispatchFunction) {
val blockchain = walletManager.wallet.blockchain
if (!blockchain.isPayIdSupported()) {
dispatch(PayIdVerification.SetError(payId, Error.PAY_ID_UNSUPPORTED_BY_BLOCKCHAIN))
return
}
scope.launch {
val result = PayIdManager().verifyPayId(payId, blockchain)
withContext(Dispatchers.Main) {
when (result) {
is Result.Success -> {
val address = result.data.getAddress()
if (address == null) {
dispatch(PayIdVerification.SetError(payId, Error.PAY_ID_NOT_REGISTERED))
return@withContext
}
val failReason = isValidBlockchainAddressAndNotTheSameAsWallet(walletManager.wallet, address)
if (failReason == null) {
dispatch(PayIdVerification.SetPayIdWalletAddress(payId, address))
} else {
dispatch(AddressVerification.SetError(payId, failReason))
}
}
is Result.Failure -> {
dispatch(PayIdVerification.SetError(payId, Error.PAY_ID_REQUEST_FAILED))
}
}
}
}
}
private fun isValidBlockchainAddressAndNotTheSameAsWallet(wallet: Wallet, address: String): Error? {
return if (wallet.blockchain.validateAddress(address)) {
if (wallet.address != address) {
null
} else {
Error.ADDRESS_SAME_AS_WALLET
}
} else {
Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN
}
}
//TODO: move to the blockchainSDK
private fun extractAddressFromShareUri(shareUri: String): String {
val sharePrefix = listOf("bitcoin:", "ethereum:", "ripple:")
val prefixes = sharePrefix.filter { shareUri.contains(it) }
return if (prefixes.isEmpty()) shareUri
else shareUri.replace(prefixes[0], "")
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.tap.features.send.redux.middlewares
import com.tangem.tap.common.CurrencyConverter
import com.tangem.tap.common.extensions.isGreaterThan
import com.tangem.tap.common.extensions.isGreaterThanOrEqual
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.send.redux.AmountAction
import com.tangem.tap.features.send.redux.ReceiptAction
import com.tangem.tap.features.send.redux.states.MainCurrencyType
import com.tangem.tap.store
import org.rekotlin.Action
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
class AmountMiddleware {
fun handle(rawData: String?, appState: AppState?, dispatch: (Action) -> Unit) {
val sendState = appState?.sendState ?: return
val rawData = rawData ?: store.state.sendState.amountState.viewAmountValue
val data = if (rawData == ".") "0.0" else rawData
val proposedAmountValue = when {
data.isEmpty() || data == "0" -> BigDecimal.ZERO
else -> BigDecimal(data)
}
val balanceCrypto = sendState.amount?.value ?: BigDecimal.ZERO
val amountChecker = when (sendState.amountState.mainCurrency.value) {
MainCurrencyType.FIAT -> FiatAmountChecker(balanceCrypto, sendState.currencyConverter)
MainCurrencyType.CRYPTO -> CryptoAmountChecker(balanceCrypto)
}
val checkResult = amountChecker.check(
proposedAmountValue,
sendState.feeState.getCurrentFee(),
sendState.feeState.feeIsIncluded
)
if (checkResult.error == null) {
dispatch(AmountAction.AmountVerification.SetAmount(checkResult.amount))
} else {
dispatch(AmountAction.AmountVerification.SetError(checkResult.amount, checkResult.error))
}
dispatch(ReceiptAction.RefreshReceipt)
}
}
interface AmountChecker {
data class Result(val amount: BigDecimal, val error: AmountAction.Error? = null)
fun check(value: BigDecimal, feeCrypto: BigDecimal, feeIsIncluded: Boolean): Result
}
abstract class BaseAmountChecker(
protected val balanceCrypto: BigDecimal
) : AmountChecker {
override fun check(value: BigDecimal, feeCrypto: BigDecimal, feeIsIncluded: Boolean): AmountChecker.Result {
val convertedFee = convert(feeCrypto)
val convertedBalance = convert(balanceCrypto)
if (feeIsIncluded) {
return if (value.isGreaterThan(convertedFee)) {
if (convertedBalance.isGreaterThanOrEqual(value)) {
AmountChecker.Result(value)
} else {
AmountChecker.Result(value, AmountAction.Error.FEE_GREATER_THAN_AMOUNT)
}
} else {
AmountChecker.Result(value, AmountAction.Error.AMOUNT_WITH_FEE_GREATER_THAN_BALANCE)
}
} else {
val amountWithFee = value.plus(convertedFee)
return if (convertedBalance.isGreaterThanOrEqual(amountWithFee)) {
AmountChecker.Result(value)
} else {
AmountChecker.Result(value, AmountAction.Error.AMOUNT_WITH_FEE_GREATER_THAN_BALANCE)
}
}
}
protected abstract fun convert(value: BigDecimal): BigDecimal
}
class CryptoAmountChecker(
balanceCrypto: BigDecimal
) : BaseAmountChecker(balanceCrypto) {
override fun convert(value: BigDecimal): BigDecimal = value
}
class FiatAmountChecker(
balanceCrypto: BigDecimal,
private val converter: CurrencyConverter
) : BaseAmountChecker(balanceCrypto) {
override fun convert(value: BigDecimal): BigDecimal = converter.toFiat(value)
}

View file

@ -0,0 +1,103 @@
package com.tangem.tap.features.send.redux.middlewares
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionSender
import com.tangem.blockchain.extensions.Result
import com.tangem.common.extensions.isZero
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.send.redux.FeeAction
import com.tangem.tap.features.send.redux.ReceiptAction
import com.tangem.tap.scope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.rekotlin.DispatchFunction
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
class RequestFeeMiddleware {
fun handle(appState: AppState?, dispatch: DispatchFunction) {
val sendState = appState?.sendState ?: return
val walletManager = sendState.walletManager ?: return
if (!sendState.addressPayIdIsReady()) {
dispatch(FeeAction.FeeCalculation.SetFeeError(FeeAction.Error.ADDRESS_OR_AMOUNT_IS_EMPTY))
dispatch(FeeAction.ChangeLayoutVisibility(main = false, controls = true, chipGroup = true))
dispatch(ReceiptAction.RefreshReceipt)
return
}
val recipientAddress = sendState.addressPayIdState.recipientWalletAddress!!
val amountState = sendState.amountState
val cryptoSendToRecipient = amountState.amountToSendCrypto
val recipientAmount = if (amountState.typeOfAmount == AmountType.Coin) {
Amount(cryptoSendToRecipient, walletManager.wallet.blockchain, type = AmountType.Coin)
} else {
Amount(cryptoSendToRecipient, walletManager.wallet.blockchain, recipientAddress, AmountType.Token)
}
val txSender = walletManager as TransactionSender
scope.launch {
val feeResult = txSender.getFee(recipientAmount, recipientAddress)
withContext(Dispatchers.Main) {
when (feeResult) {
is Result.Success -> {
val result = feeResult.data
// val result = FeeMock.getFee(walletManager.wallet.blockchain)
dispatch(FeeAction.FeeCalculation.SetFeeResult(result))
if (result.size == 1) {
val fee = result[0].value ?: BigDecimal.ZERO
if (fee.isZero()) {
dispatch(FeeAction.ChangeLayoutVisibility(main = false))
} else {
dispatch(FeeAction.ChangeLayoutVisibility(main = true, chipGroup = false))
}
} else {
dispatch(FeeAction.ChangeLayoutVisibility(main = true, controls = true, chipGroup = true))
}
}
is Result.Failure -> {
dispatch(FeeAction.FeeCalculation.SetFeeError(FeeAction.Error.REQUEST_FAILED))
dispatch(FeeAction.ChangeLayoutVisibility(main = false, controls = false, chipGroup = false))
}
}
dispatch(ReceiptAction.RefreshReceipt)
}
}
}
}
class FeeMock {
companion object {
suspend fun getFee(blockchain: Blockchain): List<Amount> {
return feeStandard(blockchain)
// return feeSingle(blockchain)
// return feeStellar(blockchain)
// return feeZero(blockchain)
}
suspend fun feeStellar(blockchain: Blockchain): List<Amount> = listOf(Amount(0.0001.toBigDecimal(), blockchain))
suspend fun feeZero(blockchain: Blockchain): List<Amount> = listOf(Amount(BigDecimal.ZERO, blockchain))
suspend fun feeStandard(blockchain: Blockchain): List<Amount> = listOf(
Amount(0.001500.toBigDecimal(), blockchain),
Amount(0.0030.toBigDecimal(), blockchain),
Amount(0.0045001.toBigDecimal(), blockchain)
)
suspend fun feeStandardBig(blockchain: Blockchain): List<Amount> = listOf(
Amount(1.76.toBigDecimal(), blockchain),
Amount(2.30.toBigDecimal(), blockchain),
Amount(3.45.toBigDecimal(), blockchain)
)
suspend fun feeSingle(blockchain: Blockchain): List<Amount> = listOf(Amount(0.0015.toBigDecimal(), blockchain))
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.tap.features.send.redux.middlewares
import com.tangem.blockchain.common.Amount
import com.tangem.common.CompletionResult
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.send.redux.AddressPayIdActionUi.ChangeAddressOrPayId
import com.tangem.tap.features.send.redux.AmountActionUi.CheckAmountToSend
import com.tangem.tap.features.send.redux.FeeAction.RequestFee
import com.tangem.tap.features.send.redux.SendAction
import com.tangem.tap.features.send.redux.SendActionUi
import com.tangem.tap.scope
import com.tangem.tap.tangemSdkManager
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
/**
[REDACTED_AUTHOR]
*/
val sendMiddleware: Middleware<AppState> = { dispatch, appState ->
{ nextDispatch ->
{ action ->
when (action) {
is ChangeAddressOrPayId -> AddressPayIdMiddleware().handle(action.data, appState(), dispatch)
is CheckAmountToSend -> AmountMiddleware().handle(action.data, appState(), dispatch)
is RequestFee -> RequestFeeMiddleware().handle(appState(), dispatch)
is SendActionUi.SendAmountToRecipient -> verifyAndSendTransaction(appState(), dispatch)
}
nextDispatch(action)
}
}
}
private fun verifyAndSendTransaction(appState: AppState?, dispatch: (Action) -> Unit) {
val sendState = appState?.sendState ?: return
val walletManager = appState.globalState.scanNoteResponse?.walletManager ?: return
val blockchain = walletManager.wallet.blockchain
val recipientAddress = sendState.addressPayIdState.recipientWalletAddress!!
val feeAmount = Amount(sendState.feeState.getCurrentFee(), blockchain)
val amountToSend = Amount(sendState.amountState.amountToSendCrypto, blockchain, recipientAddress)
scope.launch {
when (val sendResult = tangemSdkManager.send(walletManager, recipientAddress, amountToSend, feeAmount)) {
is CompletionResult.Success -> dispatch(SendAction.SendSuccess)
is CompletionResult.Failure -> {
when (sendResult.error.code) {
1021 -> dispatch(SendAction.SendError(SendAction.Error.INSUFFICIENT_BALANCE))
1001, 2000 -> dispatch(SendAction.SendError(SendAction.Error.BLOCKCHAIN_INTERNAL))
}
}
}
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.tap.features.send.redux.reducers
import com.tangem.tap.features.send.redux.AddressPayIdActionUi
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.AddressVerification
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.PayIdVerification
import com.tangem.tap.features.send.redux.SendScreenAction
import com.tangem.tap.features.send.redux.states.AddressPayIdState
import com.tangem.tap.features.send.redux.states.SendState
/**
[REDACTED_AUTHOR]
*/
class AddressPayIdReducer : SendInternalReducer {
override fun handle(action: SendScreenAction, sendState: SendState): SendState = when (action) {
is AddressPayIdActionUi -> handleUiAction(action, sendState, sendState.addressPayIdState)
is AddressPayIdVerifyAction -> handleAction(action, sendState, sendState.addressPayIdState)
else -> sendState
}
private fun handleUiAction(action: AddressPayIdActionUi, sendState: SendState, state: AddressPayIdState): SendState {
val result = when (action) {
is AddressPayIdActionUi.ChangeAddressOrPayId -> state
is AddressPayIdActionUi.SetTruncateHandler -> state.copy(truncateHandler = action.handler)
is AddressPayIdActionUi.TruncateOrRestore -> {
if (action.truncate) state.copy(etFieldValue = state.truncatedFieldValue)
else state.copy(etFieldValue = state.normalFieldValue)
}
}
return updateLastState(sendState.copy(addressPayIdState = result), result)
}
private fun handleAction(action: AddressPayIdVerifyAction, sendState: SendState, state: AddressPayIdState): SendState {
val result = when (action) {
is PayIdVerification.SetPayIdWalletAddress -> state.copyPayIdWalletAddress(action.payId, action.payIdWalletAddress)
is PayIdVerification.SetError -> state.copyPayIdError(action.payId, action.error)
is AddressVerification.SetWalletAddress -> state.copyWalletAddress(action.address)
is AddressVerification.SetError -> state.copyError(action.address, action.error)
}
return updateLastState(sendState.copy(addressPayIdState = result), result)
}
}

View file

@ -0,0 +1,120 @@
package com.tangem.tap.features.send.redux.reducers
import com.tangem.common.extensions.isZero
import com.tangem.tap.common.CurrencyConverter
import com.tangem.tap.common.entities.TapCurrency
import com.tangem.tap.common.extensions.isNegative
import com.tangem.tap.common.extensions.stripZeroPlainString
import com.tangem.tap.features.send.redux.AmountAction
import com.tangem.tap.features.send.redux.AmountActionUi
import com.tangem.tap.features.send.redux.AmountActionUi.*
import com.tangem.tap.features.send.redux.SendScreenAction
import com.tangem.tap.features.send.redux.states.AmountState
import com.tangem.tap.features.send.redux.states.MainCurrencyType
import com.tangem.tap.features.send.redux.states.SendState
import com.tangem.tap.features.send.redux.states.Value
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
class AmountReducer : SendInternalReducer {
override fun handle(action: SendScreenAction, sendState: SendState): SendState = when (action) {
is AmountActionUi -> handleUiAction(action, sendState, sendState.amountState)
is AmountAction -> handleAction(action, sendState, sendState.amountState)
else -> sendState
}
private fun handleUiAction(action: AmountActionUi, sendState: SendState, state: AmountState): SendState {
val converter = sendState.currencyConverter
val result = when (action) {
is ToggleMainCurrency -> {
val type = if (state.mainCurrency.value == MainCurrencyType.FIAT) MainCurrencyType.CRYPTO
else MainCurrencyType.FIAT
return handleUiAction(SetMainCurrency(type), sendState, state)
}
is SetMainCurrency -> {
when (action.mainCurrency) {
MainCurrencyType.FIAT -> {
val fiatToSend = if (state.amountToSendCrypto.isZero()) BigDecimal.ZERO
else converter.toFiat(state.amountToSendCrypto)
state.copy(
viewAmountValue = fiatToSend.stripZeroPlainString(),
viewBalanceValue = converter.toFiat(state.balanceCrypto).stripZeroPlainString(),
mainCurrency = Value(MainCurrencyType.FIAT, TapCurrency.main),
cursorAtTheSamePosition = false
)
}
MainCurrencyType.CRYPTO -> {
val mainCurrency = Value(MainCurrencyType.CRYPTO, sendState.amount?.currencySymbol ?: "null")
state.copy(
viewAmountValue = state.amountToSendCrypto.stripZeroPlainString(),
viewBalanceValue = state.balanceCrypto.stripZeroPlainString(),
mainCurrency = mainCurrency,
cursorAtTheSamePosition = false
)
}
}
}
is SetMaxAmount -> {
val maxAmount = if (sendState.feeState.feeIsIncluded) {
state.balanceCrypto
} else {
val balanceExtractFee = state.balanceCrypto.minus(sendState.feeState.getCurrentFee())
if (balanceExtractFee.isNegative()) BigDecimal.ZERO
else balanceExtractFee
}
val etFieldValue = if (state.mainCurrency.value == MainCurrencyType.CRYPTO) maxAmount
else converter.toFiat(maxAmount)
state.copy(
viewAmountValue = etFieldValue.stripZeroPlainString(),
amountToSendCrypto = maxAmount,
cursorAtTheSamePosition = false
)
}
is CheckAmountToSend -> return sendState
}
return updateLastState(sendState.copy(amountState = result), result)
}
private fun handleAction(action: AmountAction, sendState: SendState, state: AmountState): SendState {
val decimals = sendState.amount?.decimals ?: return sendState
val result = when (action) {
is AmountAction.AmountVerification.SetAmount -> {
setAmount(sendState.currencyConverter, decimals, action.amount, state)
}
is AmountAction.AmountVerification.SetError -> {
setAmount(sendState.currencyConverter, decimals, action.amount, state).copy(error = action.error)
}
}
return updateLastState(sendState.copy(amountState = result), result)
}
private fun setAmount(converter: CurrencyConverter, decimals: Int, amount: BigDecimal, state: AmountState): AmountState {
return when (state.mainCurrency.value) {
MainCurrencyType.FIAT -> {
val amountCrypto = converter.toCrypto(amount, decimals).stripTrailingZeros()
state.copy(
viewAmountValue = amount.stripZeroPlainString(),
amountToSendCrypto = amountCrypto,
cursorAtTheSamePosition = true,
error = null
)
}
MainCurrencyType.CRYPTO -> {
state.copy(
viewAmountValue = amount.stripZeroPlainString(),
amountToSendCrypto = amount,
cursorAtTheSamePosition = true,
error = null
)
}
}
}
}

View file

@ -0,0 +1,110 @@
package com.tangem.tap.features.send.redux.reducers
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.extensions.isAboveZero
import com.tangem.tap.common.extensions.stripZeroPlainString
import com.tangem.tap.features.send.redux.FeeAction
import com.tangem.tap.features.send.redux.FeeActionUi
import com.tangem.tap.features.send.redux.SendScreenAction
import com.tangem.tap.features.send.redux.states.FeeState
import com.tangem.tap.features.send.redux.states.FeeType
import com.tangem.tap.features.send.redux.states.SendState
import com.tangem.tap.features.send.redux.states.Value
/**
[REDACTED_AUTHOR]
*/
class FeeReducer : SendInternalReducer {
override fun handle(action: SendScreenAction, sendState: SendState): SendState = when (action) {
is FeeActionUi -> handleUiAction(action, sendState, sendState.feeState)
is FeeAction -> handleAction(action, sendState, sendState.feeState)
else -> sendState
}
private fun handleUiAction(action: FeeActionUi, sendState: SendState, state: FeeState): SendState {
val result = when (action) {
is FeeActionUi.ToggleControlsVisibility -> state.copy(controlsLayoutIsVisible = !state.controlsLayoutIsVisible)
is FeeActionUi.ChangeSelectedFee -> {
val currentFee = createValueOfFeeAmount(action.feeType, state.feeList)
state.copy(
selectedFeeType = action.feeType,
currentFee = currentFee
)
}
is FeeActionUi.ChangeIncludeFee -> state.copy(feeIsIncluded = action.isIncluded)
}
return updateLastState(sendState.copy(feeState = result), result)
}
private fun handleAction(action: FeeAction, sendState: SendState, state: FeeState): SendState {
val result = when (action) {
is FeeAction.RequestFee -> {
state.copy(error = null)
}
is FeeAction.ChangeLayoutVisibility -> {
fun getVisibility(current: Boolean, proposed: Boolean?): Boolean = proposed ?: current
state.copy(
mainLayoutIsVisible = getVisibility(state.mainLayoutIsVisible, action.main),
controlsLayoutIsVisible = getVisibility(state.controlsLayoutIsVisible, action.controls),
feeChipGroupIsVisible = getVisibility(state.mainLayoutIsVisible, action.chipGroup)
)
}
is FeeAction.FeeCalculation.SetFeeResult -> {
val fees = action.fee
if (fees.size == 1) {
val feeType = FeeType.SINGLE
val currentFee = createValueOfFeeAmount(feeType, fees)
state.copy(
selectedFeeType = feeType,
feeList = fees,
currentFee = currentFee,
error = null
)
} else {
val feeType = getCurrentFeeType(state)
val currentFee = createValueOfFeeAmount(feeType, fees)
state.copy(
selectedFeeType = feeType,
feeList = fees,
currentFee = currentFee,
error = null
)
}
}
is FeeAction.FeeCalculation.SetFeeError -> {
state.copy(
feeList = null,
currentFee = null,
error = action.error
)
}
}
return updateLastState(sendState.copy(feeState = result), result)
}
private fun createValueOfFeeAmount(feeType: FeeType, list: List<Amount>?): Value<Amount>? {
if (list == null || list.isEmpty()) return null
val feeAmount = if (list.size == 1) {
if (!list[0].isAboveZero()) return null
list[0]
} else {
when (feeType) {
FeeType.SINGLE -> list[1]
FeeType.LOW -> list[0]
FeeType.NORMAL -> list[1]
FeeType.PRIORITY -> list[2]
}
}
return Value(feeAmount, feeAmount.value?.stripZeroPlainString() ?: "")
}
private fun getCurrentFeeType(state: FeeState): FeeType {
return if (state.selectedFeeType == FeeType.SINGLE) FeeType.NORMAL else state.selectedFeeType
}
}

View file

@ -0,0 +1,162 @@
package com.tangem.tap.features.send.redux.reducers
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Wallet
import com.tangem.tap.common.CurrencyConverter
import com.tangem.tap.common.entities.TapCurrency
import com.tangem.tap.features.send.redux.ReceiptAction.RefreshReceipt
import com.tangem.tap.features.send.redux.SendScreenAction
import com.tangem.tap.features.send.redux.states.*
/**
[REDACTED_AUTHOR]
*/
class ReceiptReducer : SendInternalReducer {
override fun handle(action: SendScreenAction, sendState: SendState): SendState {
return when (action) {
is RefreshReceipt -> handleRefresh(action, sendState, sendState.receiptState)
else -> sendState
}
}
private fun handleRefresh(action: RefreshReceipt, sendState: SendState, state: ReceiptState): SendState {
val wallet = sendState.walletManager?.wallet ?: return sendState
val converter = sendState.currencyConverter
val amountState = sendState.amountState
val feeState = sendState.feeState
val layoutType = determineLayoutType(amountState.mainCurrency.value, amountState.typeOfAmount)
val symbols = determineSymbols(wallet)
val result = state.copy(
visibleTypeOfReceipt = layoutType,
mainCurrencyType = sendState.amountState.mainCurrency,
mainLayoutIsVisible = sendState.isReadyToSend(),
fiat = createFiatType(converter, amountState, feeState, symbols),
crypto = createCryptoType(converter, amountState, feeState, symbols),
tokenFiat = createTokenFiatType(converter, amountState, feeState, symbols),
tokenCrypto = createTokenCryptoType(converter, amountState, feeState, symbols)
)
return updateLastState(sendState.copy(receiptState = result), result)
}
private fun createFiatType(
converter: CurrencyConverter,
amountState: AmountState,
feeState: FeeState,
symbols: ReceiptSymbols
): ReceiptFiat {
val feeCrypto = feeState.getCurrentFee()
val feeFiat = converter.toFiat(feeCrypto)
return if (feeState.feeIsIncluded) {
val amountFiat = converter.toFiat(amountState.amountToSendCrypto.minus(feeCrypto))
val totalFiat = converter.toFiat(amountState.amountToSendCrypto)
ReceiptFiat(
amountFiat = amountFiat,
feeFiat = feeFiat,
totalFiat = totalFiat,
willSentCrypto = amountState.amountToSendCrypto.stripTrailingZeros(),
symbols = symbols
)
} else {
val totalAmountCrypto = amountState.amountToSendCrypto.plus(feeCrypto)
val amountFiat = converter.toFiat(amountState.amountToSendCrypto)
val totalFiat = converter.toFiat(totalAmountCrypto)
ReceiptFiat(
amountFiat = amountFiat,
feeFiat = feeFiat,
totalFiat = totalFiat,
willSentCrypto = totalAmountCrypto.stripTrailingZeros(),
symbols = symbols
)
}
}
private fun createCryptoType(
converter: CurrencyConverter,
amountState: AmountState,
feeState: FeeState,
symbols: ReceiptSymbols
): ReceiptCrypto {
val feeCrypto = feeState.getCurrentFee()
if (feeState.feeIsIncluded) {
return ReceiptCrypto(
amountCrypto = amountState.amountToSendCrypto.minus(feeCrypto).stripTrailingZeros(),
feeCrypto = feeCrypto.stripTrailingZeros(),
totalCrypto = amountState.amountToSendCrypto.stripTrailingZeros(),
willSentFiat = converter.toFiat(amountState.amountToSendCrypto),
symbols = symbols
)
} else {
val totalCrypto = amountState.amountToSendCrypto.plus(feeCrypto)
return ReceiptCrypto(
amountCrypto = amountState.amountToSendCrypto.stripTrailingZeros(),
feeCrypto = feeCrypto.stripTrailingZeros(),
totalCrypto = totalCrypto.stripTrailingZeros(),
willSentFiat = converter.toFiat(totalCrypto),
symbols = symbols
)
}
}
private fun createTokenFiatType(
converter: CurrencyConverter,
amountState: AmountState,
feeState: FeeState,
symbols: ReceiptSymbols
): ReceiptTokenFiat {
val feeCrypto = feeState.getCurrentFee()
val amountFiat = converter.toFiat(amountState.amountToSendCrypto)
val feeFiat = converter.toFiat(feeCrypto)
return ReceiptTokenFiat(
amountFiat = amountFiat,
feeFiat = feeFiat,
totalFiat = amountFiat.plus(feeFiat),
willSentTokenCrypto = amountState.amountToSendCrypto.stripTrailingZeros(),
willSentFeeCrypto = feeCrypto.stripTrailingZeros(),
symbols = symbols
)
}
private fun createTokenCryptoType(
converter: CurrencyConverter,
amountState: AmountState,
feeState: FeeState,
symbols: ReceiptSymbols
): ReceiptTokenCrypto {
val feeCrypto = feeState.getCurrentFee()
val totalFiat = converter.toFiat(amountState.amountToSendCrypto).plus(converter.toFiat(feeCrypto))
return ReceiptTokenCrypto(
amountToken = amountState.amountToSendCrypto.stripTrailingZeros(),
feeCrypto = feeCrypto.stripTrailingZeros(),
totalFiat = totalFiat.stripTrailingZeros(),
symbols = symbols
)
}
private fun determineSymbols(wallet: Wallet): ReceiptSymbols {
return ReceiptSymbols(
fiat = TapCurrency.main,
crypto = wallet.blockchain.currency,
token = wallet.amounts[AmountType.Token]?.currencySymbol
)
}
private fun determineLayoutType(mainCurrencyType: MainCurrencyType, amountType: AmountType): ReceiptLayoutType {
return when (mainCurrencyType) {
MainCurrencyType.FIAT -> when (amountType) {
AmountType.Coin -> ReceiptLayoutType.FIAT
AmountType.Token -> ReceiptLayoutType.TOKEN_FIAT
AmountType.Reserve -> ReceiptLayoutType.UNKNOWN
}
MainCurrencyType.CRYPTO -> when (amountType) {
AmountType.Coin -> ReceiptLayoutType.CRYPTO
AmountType.Token -> ReceiptLayoutType.TOKEN_CRYPTO
AmountType.Reserve -> ReceiptLayoutType.UNKNOWN
}
}
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.tap.features.send.redux.reducers
import com.tangem.blockchain.common.WalletManager
import com.tangem.tap.common.CurrencyConverter
import com.tangem.tap.features.send.redux.*
import com.tangem.tap.features.send.redux.states.SendState
import com.tangem.tap.store
import org.rekotlin.Action
import org.rekotlin.StateType
import timber.log.Timber
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
interface SendInternalReducer {
fun handle(action: SendScreenAction, sendState: SendState): SendState
}
class SendReducer {
companion object {
fun reduce(incomingAction: Action, sendState: SendState): SendState {
if (incomingAction is ReleaseSendState) return SendState()
val action = incomingAction as? SendScreenAction ?: return sendState
val reducer: SendInternalReducer = when (action) {
is PrepareSendScreen -> PrepareSendScreenStatesReducer()
is AddressPayIdActionUi, is AddressPayIdVerifyAction -> AddressPayIdReducer()
is AmountActionUi, is AmountAction -> AmountReducer()
is FeeActionUi, is FeeAction -> FeeReducer()
is ReceiptAction -> ReceiptReducer()
else -> EmptyReducer()
}
val newState = reducer.handle(action, sendState).copy(sendButtonIsEnabled = sendState.isReadyToSend())
Timber.i("${newState.lastChangedStateType}.")
return newState
}
}
}
private class EmptyReducer : SendInternalReducer {
override fun handle(action: SendScreenAction, sendState: SendState): SendState = sendState
}
private class PrepareSendScreenStatesReducer : SendInternalReducer {
override fun handle(action: SendScreenAction, sendState: SendState): SendState {
val amount = (action as PrepareSendScreen).amount
val walletManager = store.state.globalState.scanNoteResponse!!.walletManager!!
return sendState.copy(
amount = amount,
walletManager = walletManager,
currencyConverter = createCurrencyConverter(walletManager),
amountState = sendState.amountState.copy(balanceCrypto = amount.value ?: BigDecimal.ZERO)
)
}
private fun createCurrencyConverter(walletManager: WalletManager): CurrencyConverter {
val rate = store.state.globalState.fiatRates.getRateForCryptoCurrency(walletManager.wallet.blockchain.currency)
return if (rate == null) CurrencyConverter(BigDecimal.ONE) else CurrencyConverter(rate)
}
}
internal fun updateLastState(sendState: SendState, lastChangedState: StateType): SendState =
sendState.copy(lastChangedStateType = lastChangedState)

View file

@ -0,0 +1,62 @@
package com.tangem.tap.features.send.redux.states
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction
import org.rekotlin.StateType
data class AddressPayIdState(
val etFieldValue: String? = null,
val normalFieldValue: String? = null,
val truncatedFieldValue: String? = null,
val recipientWalletAddress: String? = null,
val error: AddressPayIdVerifyAction.Error? = null,
val truncateHandler: ((String) -> String)? = null
) : StateType {
fun isReady(): Boolean = error == null && recipientWalletAddress?.isNotEmpty() ?: false
fun isPayIdState(): Boolean = recipientWalletAddress != null && recipientWalletAddress != normalFieldValue
fun copyWalletAddress(address: String): AddressPayIdState {
val truncated = truncateHandler?.invoke(address) ?: address
return this.copy(
etFieldValue = address,
normalFieldValue = address,
truncatedFieldValue = truncated,
recipientWalletAddress = address,
error = null
)
}
fun copyError(address: String, error: AddressPayIdVerifyAction.Error): AddressPayIdState {
val truncated = truncateHandler?.invoke(address) ?: address
return this.copy(
etFieldValue = address,
normalFieldValue = address,
truncatedFieldValue = truncated,
error = error,
recipientWalletAddress = null
)
}
fun copyPayIdWalletAddress(payId: String, address: String): AddressPayIdState {
val truncated = truncateHandler?.invoke(address) ?: address
return this.copy(
etFieldValue = payId,
normalFieldValue = payId,
truncatedFieldValue = truncated,
recipientWalletAddress = address,
error = null
)
}
fun copyPayIdError(payId: String, error: AddressPayIdVerifyAction.Error): AddressPayIdState {
val truncated = truncateHandler?.invoke(payId) ?: payId
return this.copy(
etFieldValue = payId,
normalFieldValue = payId,
truncatedFieldValue = truncated,
error = error,
recipientWalletAddress = null
)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.tap.features.send.redux.states
import com.tangem.blockchain.common.Amount
import com.tangem.tap.features.send.redux.FeeAction
import org.rekotlin.StateType
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
enum class FeeType {
SINGLE, LOW, NORMAL, PRIORITY
}
data class FeeState(
val selectedFeeType: FeeType = FeeType.NORMAL,
val feeList: List<Amount>? = null,
val currentFee: Value<Amount>? = null,
val feeIsIncluded: Boolean = false,
val mainLayoutIsVisible: Boolean = false,
val controlsLayoutIsVisible: Boolean = true,
val feeChipGroupIsVisible: Boolean = true,
val error: FeeAction.Error? = null
) : StateType {
fun isReady(): Boolean = error == null && currentFee != null
fun getCurrentFee(): BigDecimal = currentFee?.value?.value ?: BigDecimal.ZERO
}

View file

@ -0,0 +1,57 @@
package com.tangem.tap.features.send.redux.states
import org.rekotlin.StateType
import java.math.BigDecimal
// Shows only one type of the layout
enum class ReceiptLayoutType {
UNKNOWN, FIAT, CRYPTO, TOKEN_FIAT, TOKEN_CRYPTO
}
data class ReceiptState(
val visibleTypeOfReceipt: ReceiptLayoutType? = null,
val fiat: ReceiptFiat? = null,
val crypto: ReceiptCrypto? = null,
val tokenFiat: ReceiptTokenFiat? = null,
val tokenCrypto: ReceiptTokenCrypto? = null,
val mainCurrencyType: Value<MainCurrencyType>? = null,
val mainLayoutIsVisible: Boolean = false,
) : StateType
data class ReceiptSymbols(
val fiat: String,
val crypto: String,
val token: String? = null
)
data class ReceiptFiat(
val amountFiat: BigDecimal,
val feeFiat: BigDecimal,
val totalFiat: BigDecimal,
val willSentCrypto: BigDecimal,
val symbols: ReceiptSymbols
)
data class ReceiptCrypto(
val amountCrypto: BigDecimal,
val feeCrypto: BigDecimal,
val totalCrypto: BigDecimal,
val willSentFiat: BigDecimal,
val symbols: ReceiptSymbols
)
data class ReceiptTokenCrypto(
val amountToken: BigDecimal,
val feeCrypto: BigDecimal,
val totalFiat: BigDecimal,
val symbols: ReceiptSymbols
)
data class ReceiptTokenFiat(
val amountFiat: BigDecimal,
val feeFiat: BigDecimal,
val totalFiat: BigDecimal,
val willSentTokenCrypto: BigDecimal,
val willSentFeeCrypto: BigDecimal,
val symbols: ReceiptSymbols
)

View file

@ -0,0 +1,59 @@
package com.tangem.tap.features.send.redux.states
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.extensions.isZero
import com.tangem.tap.common.CurrencyConverter
import com.tangem.tap.common.entities.TapCurrency
import com.tangem.tap.features.send.redux.AmountAction
import com.tangem.tap.store
import org.rekotlin.StateType
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
data class SendState(
val amount: Amount? = null,
val walletManager: WalletManager? = null,
val currencyConverter: CurrencyConverter = CurrencyConverter(BigDecimal.ONE),
val lastChangedStateType: StateType = NoneState(),
val addressPayIdState: AddressPayIdState = AddressPayIdState(),
val amountState: AmountState = AmountState(),
val feeState: FeeState = FeeState(),
val receiptState: ReceiptState = ReceiptState(),
val sendButtonIsEnabled: Boolean = false,
) : StateType {
fun isReadyToSend(): Boolean {
val sendState = store.state.sendState
return addressPayIdIsReady() && sendState.amountState.isReady() && sendState.feeState.isReady()
}
fun addressPayIdIsReady(): Boolean = store.state.sendState.addressPayIdState.isReady()
}
class NoneState : StateType
data class AmountState(
val viewAmountValue: String = BigDecimal.ZERO.toPlainString(),
val viewBalanceValue: String = BigDecimal.ZERO.toPlainString(),
val mainCurrency: Value<MainCurrencyType> = Value(MainCurrencyType.FIAT, TapCurrency.main),
val typeOfAmount: AmountType = AmountType.Coin,
val amountToSendCrypto: BigDecimal = BigDecimal.ZERO,
val balanceCrypto: BigDecimal = BigDecimal.ZERO,
val cursorAtTheSamePosition: Boolean = true,
val error: AmountAction.Error? = null
) : StateType {
fun isReady(): Boolean = error == null && !amountToSendCrypto.isZero()
}
enum class MainCurrencyType {
FIAT, CRYPTO
}
data class Value<T>(
val value: T,
val displayedValue: String
)

View file

@ -4,30 +4,38 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.core.view.postDelayed
import androidx.core.widget.addTextChangedListener
import com.tangem.tangem_sdk_new.extensions.hideSoftKeyboard
import com.tangem.tap.common.KeyboardObserver
import com.tangem.tap.common.entities.TapCurrency
import com.tangem.tap.common.extensions.getFromClipboard
import com.tangem.tap.common.extensions.setOnImeActionListener
import com.tangem.tap.common.qrCodeScan.ScanQrCodeActivity
import com.tangem.tap.common.snackBar.MaxAmountSnackbar
import com.tangem.tap.common.text.truncateMiddleWith
import com.tangem.tap.features.send.BaseStoreFragment
import com.tangem.tap.features.send.redux.AddressPayIdActionUi.*
import com.tangem.tap.features.send.redux.AmountActionUi.*
import com.tangem.tap.features.send.redux.FeeAction
import com.tangem.tap.features.send.redux.FeeActionUi.*
import com.tangem.tap.features.send.redux.MainCurrencyType
import com.tangem.tap.features.send.redux.ReceiptAction
import com.tangem.tap.features.send.redux.ReleaseSendState
import com.tangem.tap.features.send.redux.SendActionUi
import com.tangem.tap.features.send.redux.states.FeeType
import com.tangem.tap.features.send.redux.states.MainCurrencyType
import com.tangem.tap.features.send.ui.stateSubscribers.SendStateSubscriber
import com.tangem.tap.mainScope
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.android.synthetic.main.btn_paste.*
import kotlinx.android.synthetic.main.btn_qr_code.*
import kotlinx.android.synthetic.main.fragment_send.*
import kotlinx.android.synthetic.main.layout_send_address_payid.*
import kotlinx.android.synthetic.main.layout_send_amount.*
import kotlinx.android.synthetic.main.layout_send_network_fee.*
import kotlinx.android.synthetic.main.layout_send_fee.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
@ -46,6 +54,10 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
setupAddressOrPayIdLayout()
setupAmountLayout()
setupFeeLayout()
btnSend.setOnClickListener {
store.dispatch(SendActionUi.SendAmountToRecipient)
}
}
private fun setupAddressOrPayIdLayout() {
@ -54,15 +66,19 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
etAddressOrPayId.setOnFocusChangeListener { v, hasFocus ->
store.dispatch(TruncateOrRestore(!hasFocus))
}
etAddressOrPayId.inputedTextAsFlow()
etAddressOrPayId.inputtedTextAsFlow()
.debounce(400)
.filter { store.state.sendState.addressPayIdState.etFieldValue != it }
.onEach { store.dispatch(ChangeAddressOrPayId(it)) }
.onEach {
store.dispatch(ChangeAddressOrPayId(it))
store.dispatch(FeeAction.RequestFee)
}
.launchIn(mainScope)
imvPaste.setOnClickListener {
store.dispatch(ChangeAddressOrPayId(requireContext().getFromClipboard()?.toString() ?: ""))
store.dispatch(TruncateOrRestore(!etAddressOrPayId.isFocused))
store.dispatch(FeeAction.RequestFee)
}
imvQrCode.setOnClickListener {
startActivityForResult(
@ -78,13 +94,22 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
val scannedCode = data?.getStringExtra(ScanQrCodeActivity.SCAN_RESULT) ?: ""
store.dispatch(ChangeAddressOrPayId(scannedCode))
store.dispatch(TruncateOrRestore(!etAddressOrPayId.isFocused))
view?.postDelayed(200) { store.dispatch(FeeAction.RequestFee) }
}
private fun setupAmountLayout() {
store.dispatch(SetMainCurrency(restoreMainCurrency()))
tvAmountCurrency.setOnClickListener { store.dispatch(ToggleMainCurrency) }
tvAmountCurrency.setOnClickListener {
store.dispatch(ToggleMainCurrency)
store.dispatch(ReceiptAction.RefreshReceipt)
}
val maxAmountSnackbar = MaxAmountSnackbar.make(etAmountToSend) { store.dispatch(SetMaxAmount) }
val maxAmountSnackbar = MaxAmountSnackbar.make(etAmountToSend) {
etAmountToSend.clearFocus()
etAmountToSend.postDelayed(200) { etAmountToSend.hideSoftKeyboard() }
store.dispatch(SetMaxAmount)
store.dispatch(CheckAmountToSend())
}
var snackbarControlledByChangingFocus = false
keyboardObserver = KeyboardObserver(requireActivity())
keyboardObserver.registerListener { isShow ->
@ -103,7 +128,6 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
etAmountToSend.postDelayed(200) {
maxAmountSnackbar.show()
snackbarControlledByChangingFocus = false
}
} else {
etAmountToSend.postDelayed(350) {
@ -120,33 +144,44 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
if (!hasFocus && etAmountToSend.text?.toString() == "") etAmountToSend.setText("0")
}
etAmountToSend.inputedTextAsFlow()
etAmountToSend.inputtedTextAsFlow()
.debounce(400)
.filter { store.state.sendState.amountState.etAmountFieldValue != it }
.onEach { store.dispatch(ChangeAmountToSend(it)) }
.filter { store.state.sendState.amountState.viewAmountValue != it && it.isNotEmpty() }
.onEach { store.dispatch(CheckAmountToSend(it)) }
.launchIn(mainScope)
etAmountToSend.setOnImeActionListener(EditorInfo.IME_ACTION_DONE) {
it.hideSoftKeyboard()
it.clearFocus()
}
}
private fun setupFeeLayout() {
flExpandCollapse.setOnClickListener {
store.dispatch(ToggleFeeLayoutVisibility)
store.dispatch(ToggleControlsVisibility)
}
chipGroup.setOnCheckedChangeListener { group, checkedId ->
store.dispatch(ChangeSelectedFee(checkedId))
if (checkedId == -1) return@setOnCheckedChangeListener
store.dispatch(ChangeSelectedFee(FeeUiHelper.idToFee(checkedId)))
store.dispatch(CheckAmountToSend())
}
swIncludeFee.setOnCheckedChangeListener { btn, isChecked ->
store.dispatch(ChangeIncludeFee(isChecked))
store.dispatch(CheckAmountToSend())
}
}
override fun subscribeToStore() {
store.subscribe(sendSubscriber) { appState ->
appState.skipRepeats { oldState, newState -> oldState == newState }.select { it.sendState }
appState.skipRepeats { oldState, newState ->
oldState.sendState == newState.sendState
}.select { it.sendState }
}
storeSubscribersList.add(sendSubscriber)
}
fun restoreMainCurrency(): MainCurrencyType {
private fun restoreMainCurrency(): MainCurrencyType {
val sp = requireContext().getSharedPreferences("SendScreen", Context.MODE_PRIVATE)
val mainCurrency = sp.getString("mainCurrency", TapCurrency.main)
val foundType = MainCurrencyType.values()
@ -167,10 +202,32 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
}
@ExperimentalCoroutinesApi
fun EditText.inputedTextAsFlow(): Flow<String> = callbackFlow {
fun EditText.inputtedTextAsFlow(): Flow<String> = callbackFlow {
val watcher = addTextChangedListener { editable -> offer(editable?.toString() ?: "") }
awaitClose { removeTextChangedListener(watcher) }
}
class FeeUiHelper {
companion object {
fun feeToId(fee: FeeType): Int {
return when (fee) {
FeeType.SINGLE -> 0
FeeType.LOW -> R.id.chipLow
FeeType.NORMAL -> R.id.chipNormal
FeeType.PRIORITY -> R.id.chipPriority
}
}
fun idToFee(id: Int): FeeType {
return when (id) {
R.id.chipLow -> FeeType.LOW
R.id.chipNormal -> FeeType.NORMAL
R.id.chipPriority -> FeeType.PRIORITY
else -> FeeType.NORMAL
}
}
}
}

View file

@ -1,6 +1,6 @@
package com.tangem.tap.features.send.ui.stateSubscribers
import androidx.fragment.app.Fragment
import com.tangem.tap.features.send.BaseStoreFragment
import org.rekotlin.StateType
import org.rekotlin.StoreSubscriber
import java.lang.ref.WeakReference
@ -8,10 +8,10 @@ import java.lang.ref.WeakReference
/**
[REDACTED_AUTHOR]
*/
abstract class FragmentStateSubscriber<S : StateType>(fragment: Fragment) : StoreSubscriber<S> {
private val weakFragment: WeakReference<Fragment> = WeakReference(fragment)
abstract class FragmentStateSubscriber<S : StateType>(fragment: BaseStoreFragment) : StoreSubscriber<S> {
private val weakFragment: WeakReference<BaseStoreFragment> = WeakReference(fragment)
abstract fun updateWithNewState(fg: Fragment, state: S)
abstract fun updateWithNewState(fg: BaseStoreFragment, state: S)
override fun newState(state: S) {
val fg = weakFragment.get() ?: return

View file

@ -1,49 +1,58 @@
package com.tangem.tap.features.send.ui.stateSubscribers
import android.content.Context
import android.text.SpannableStringBuilder
import android.util.TypedValue
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.core.text.bold
import com.tangem.tap.common.extensions.beginDelayedTransition
import com.tangem.tap.common.extensions.enableError
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.extensions.update
import com.tangem.tap.features.send.redux.AddressPayIdState
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.FailReason
import com.tangem.tap.features.send.redux.AmountState
import com.tangem.tap.features.send.redux.FeeLayoutState
import com.tangem.tap.features.send.redux.SendState
import com.tangem.tap.features.send.BaseStoreFragment
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction.Error
import com.tangem.tap.features.send.redux.AmountAction
import com.tangem.tap.features.send.redux.FeeAction
import com.tangem.tap.features.send.redux.states.*
import com.tangem.tap.features.send.ui.FeeUiHelper
import com.tangem.tap.features.send.ui.SendFragment
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.android.synthetic.main.btn_expand_collapse.*
import kotlinx.android.synthetic.main.fragment_send.*
import kotlinx.android.synthetic.main.fragment_send.clReceiptContainer
import kotlinx.android.synthetic.main.layout_receipt_total.*
import kotlinx.android.synthetic.main.layout_receipt_total.view.*
import kotlinx.android.synthetic.main.layout_send_address_payid.*
import kotlinx.android.synthetic.main.layout_send_amount.*
import kotlinx.android.synthetic.main.layout_send_network_fee.*
import kotlinx.android.synthetic.main.layout_send_fee.*
import kotlinx.android.synthetic.main.layout_send_receipt.*
/**
[REDACTED_AUTHOR]
*/
class SendStateSubscriber(fragment: Fragment) : FragmentStateSubscriber<SendState>(fragment) {
class SendStateSubscriber(fragment: BaseStoreFragment) : FragmentStateSubscriber<SendState>(fragment) {
override fun updateWithNewState(fg: Fragment, state: SendState) {
override fun updateWithNewState(fg: BaseStoreFragment, state: SendState) {
when (state.lastChangedStateType) {
is FeeLayoutState -> handleFeeLayoutState(fg, state.feeLayoutState)
is FeeState -> handleFeeState(fg, state.feeState)
is AddressPayIdState -> handleAddressPayIdState(fg, state.addressPayIdState)
is AmountState -> handleAmountState(fg, state.amountState)
is ReceiptState -> handleReceiptState(fg, state.receiptState)
}
fg.btnSend.isEnabled = state.sendButtonIsEnabled
}
private fun handleAddressPayIdState(fg: Fragment, state: AddressPayIdState) {
fun parseError(context: Context, error: FailReason?): String? {
private fun handleAddressPayIdState(fg: BaseStoreFragment, state: AddressPayIdState) {
fun parseError(context: Context, error: Error?): String? {
val resId = when (error) {
FailReason.IS_NOT_PAY_ID -> R.string.error_payid_verification_failed
FailReason.PAY_ID_UNSUPPORTED_BY_BLOCKCHAIN -> R.string.error_payid_unsupported_by_blockchain
FailReason.PAY_ID_NOT_REGISTERED -> R.string.error_payid_not_registere
FailReason.PAY_ID_REQUEST_FAILED -> R.string.error_payid_request_failed
FailReason.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN -> R.string.error_address_invalid_or_unsupported
FailReason.ADDRESS_SAME_AS_WALLET -> R.string.error_address_same_as_wallet
Error.IS_NOT_PAY_ID -> R.string.error_payid_verification_failed
Error.PAY_ID_UNSUPPORTED_BY_BLOCKCHAIN -> R.string.error_payid_unsupported_by_blockchain
Error.PAY_ID_NOT_REGISTERED -> R.string.error_payid_not_registere
Error.PAY_ID_REQUEST_FAILED -> R.string.error_payid_request_failed
Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN -> R.string.error_address_invalid_or_unsupported
Error.ADDRESS_SAME_AS_WALLET -> R.string.error_address_same_as_wallet
else -> null
}
return if (resId == null) null
@ -54,9 +63,10 @@ class SendStateSubscriber(fragment: Fragment) : FragmentStateSubscriber<SendStat
val til = fg.tilAddressOrPayId
val parsedError = parseError(til.context, state.error)
til.parent?.parent?.beginDelayedTransition()
til.error = parsedError
til.isErrorEnabled = parsedError != null
til.helperText = state.walletAddress
til.helperText = state.recipientWalletAddress
til.isHelperTextEnabled = state.isPayIdState() && parsedError == null
// prevent cycling
@ -65,28 +75,23 @@ class SendStateSubscriber(fragment: Fragment) : FragmentStateSubscriber<SendStat
et.update(state.etFieldValue)
}
private fun handleFeeLayoutState(fg: Fragment, layoutState: FeeLayoutState) {
if (fg.llFeeContainer.visibility != layoutState.visibility) {
val rotationAngle = if (fg.imvExpandCollapse.rotation == 0f) 180f else 0f
fg.imvExpandCollapse.rotation = rotationAngle
(fg.llFeeContainer.parent?.parent as? ViewGroup)?.beginDelayedTransition()
fg.llFeeContainer.visibility = layoutState.visibility
private fun handleAmountState(fg: BaseStoreFragment, state: AmountState) {
when (state.error) {
AmountAction.Error.FEE_GREATER_THAN_AMOUNT -> {
fg.amountContainer.parent?.beginDelayedTransition()
fg.tilAmountToSend.enableError(true, fg.getString(R.string.error_fee_greater_than_amount))
}
AmountAction.Error.AMOUNT_WITH_FEE_GREATER_THAN_BALANCE -> {
fg.amountContainer.parent?.beginDelayedTransition()
fg.tilAmountToSend.enableError(true, fg.getString(R.string.error_amount_with_fee_greater_than_balance))
}
null -> {
if (fg.tilAmountToSend.isErrorEnabled) fg.amountContainer.parent?.beginDelayedTransition()
fg.tilAmountToSend.enableError(false)
}
}
if (fg.swIncludeFee.isChecked != layoutState.includeFeeIsChecked) {
fg.swIncludeFee.isChecked = layoutState.includeFeeIsChecked
}
if (fg.chipGroup.checkedChipId != layoutState.selectedFeeId) {
fg.chipGroup.check(layoutState.selectedFeeId)
}
}
private fun handleAmountState(fg: Fragment, state: AmountState) {
fg.tilAmountToSend.enableError(state.amountIsOverBalance)
val amountToSend = state.etAmountFieldValue
val amountToSend = state.viewAmountValue
fg.tvAmountToSendShadow.text = amountToSend
if (amountToSend.length > 10) {
// post is needed to wait for text size changes
@ -104,11 +109,134 @@ class SendStateSubscriber(fragment: Fragment) : FragmentStateSubscriber<SendStat
}
fg.tvAmountCurrency.update(state.mainCurrency.displayedValue)
(fg as? SendFragment)?.let { it.saveMainCurrency(state.mainCurrency.value) }
(fg as? SendFragment)?.saveMainCurrency(state.mainCurrency.value)
val balanceText = fg.getString(R.string.send_balance,
state.mainCurrency.displayedValue,
state.balance.toPlainString())
state.viewBalanceValue)
fg.tvBalance.update(balanceText)
}
private fun handleFeeState(fg: BaseStoreFragment, state: FeeState) {
var delayedTransitionScheduled = false
fg.view?.findViewById<ViewGroup>(R.id.clNetworkFee)?.let {
it.show(state.mainLayoutIsVisible) {
(it.parent as? ViewGroup)?.beginDelayedTransition()
delayedTransitionScheduled = true
}
}
fg.imvExpandCollapse.rotation = if (state.controlsLayoutIsVisible) 0f else 180f
fg.llFeeControlsContainer.show(state.controlsLayoutIsVisible) {
if (!delayedTransitionScheduled) {
fg.llFeeControlsContainer.parent?.parent?.beginDelayedTransition()
}
}
fg.chipGroup.show(state.feeChipGroupIsVisible) {
if (!delayedTransitionScheduled) {
fg.llFeeControlsContainer.parent?.parent?.beginDelayedTransition()
}
}
if (fg.swIncludeFee.isChecked != state.feeIsIncluded) {
fg.swIncludeFee.isChecked = state.feeIsIncluded
}
if (state.error == FeeAction.Error.REQUEST_FAILED) {
fg.showRetrySnackbar(fg.requireContext().getString(R.string.error_fee_request_failed)) {
store.dispatch(FeeAction.RequestFee)
}
}
val chipId = FeeUiHelper.feeToId(state.selectedFeeType)
if (fg.chipGroup.checkedChipId != chipId && chipId != 0) fg.chipGroup.check(chipId)
}
private fun handleReceiptState(fg: BaseStoreFragment, state: ReceiptState) {
val mainLayout = fg.clReceiptContainer as ViewGroup
val totalLayout = fg.llTotal as ViewGroup
val totalTokenLayout = fg.flTotalTokenCrypto as ViewGroup
fun getString(id: Int): String = mainLayout.context.getString(id)
mainLayout.show(state.mainLayoutIsVisible)
when (state.visibleTypeOfReceipt) {
ReceiptLayoutType.FIAT -> {
val receipt = state.fiat ?: return
totalLayout.show(true)
totalTokenLayout.show(false)
fg.tvReceiptAmountValue.update("${receipt.amountFiat.toPlainString()} ${receipt.symbols.fiat}")
fg.tvReceiptFeeValue.update("${receipt.feeFiat.toPlainString()} ${receipt.symbols.fiat}")
totalLayout.tvTotalValue.update("${receipt.totalFiat.toPlainString()} ${receipt.symbols.fiat}")
val willSent = SpannableStringBuilder()
.bold { append(receipt.willSentCrypto.toPlainString()) }.append(" ")
.append(receipt.symbols.crypto).append(" ")
.append(getString(R.string.send_total_will_be_sent))
totalLayout.tvWillBeSentValue.update(willSent)
}
ReceiptLayoutType.CRYPTO -> {
val receipt = state.crypto ?: return
totalLayout.show(true)
totalTokenLayout.show(false)
fg.tvReceiptAmountValue.update("${receipt.amountCrypto.toPlainString()} ${receipt.symbols.crypto}")
fg.tvReceiptFeeValue.update("${receipt.feeCrypto.toPlainString()} ${receipt.symbols.crypto}")
totalLayout.tvTotalValue.update("${receipt.totalCrypto.toPlainString()} ${receipt.symbols.crypto}")
val willSent = SpannableStringBuilder()
.bold {
append(getString(R.string.sign_rough))
append(" ")
append(receipt.willSentFiat.toPlainString())
append(" ")
append(receipt.symbols.fiat)
}
totalLayout.tvWillBeSentValue.update(willSent)
}
ReceiptLayoutType.TOKEN_FIAT -> {
val receipt = state.tokenFiat ?: return
totalLayout.show(true)
totalTokenLayout.show(false)
fg.tvReceiptAmountValue.update("${receipt.amountFiat.toPlainString()} ${receipt.symbols.fiat}")
fg.tvReceiptFeeValue.update("${receipt.feeFiat.toPlainString()} ${receipt.symbols.fiat}")
totalLayout.tvTotalValue.update("${receipt.totalFiat.toPlainString()} ${receipt.symbols.fiat}")
val willSent = SpannableStringBuilder()
.bold {
append(receipt.symbols.token)
append(" ")
append(receipt.willSentFeeCrypto.toPlainString())
}.append(" ").append(getString(R.string.generic_and)).append(" ")
.bold {
append(receipt.symbols.crypto).append(" ")
append(receipt.willSentFeeCrypto.toPlainString()).append(" ")
}
.append(mainLayout.context.getString(R.string.send_total_will_be_sent))
totalLayout.tvWillBeSentValue.update(willSent)
}
ReceiptLayoutType.TOKEN_CRYPTO -> {
val receipt = state.tokenCrypto ?: return
totalLayout.show(false)
totalTokenLayout.show(true)
fg.tvReceiptAmountValue.update("${receipt.amountToken.toPlainString()} ${receipt.symbols.token}")
fg.tvReceiptFeeValue.update("${receipt.feeCrypto.toPlainString()} ${receipt.symbols.crypto}")
val willSent = SpannableStringBuilder()
.bold {
append(getString(R.string.sign_rough))
append(" ")
append(receipt.totalFiat.toPlainString())
append(" ")
append(receipt.symbols.fiat)
}
totalTokenLayout.tvTotalTokenCryptoValue.update(willSent)
}
}
}
}

View file

@ -18,7 +18,6 @@
android:layout_gravity="center"
android:background="?selectableItemBackgroundBorderless"
android:padding="5dp"
android:rotation="180"
app:srcCompat="@drawable/ic_angle_bracket_up" />
</FrameLayout>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="24dp"
@ -39,23 +40,28 @@
<include
android:id="@+id/clNetworkFee"
layout="@layout/layout_send_network_fee"
layout="@layout/layout_send_fee"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp" />
android:layout_marginTop="16dp"
android:visibility="gone"
tools:visibility="visible" />
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
android:layout_weight="1"
android:minHeight="1dp" />
<include
android:id="@+id/clNetworkFee"
layout="@layout/layout_send_total"
android:id="@+id/clReceiptContainer"
layout="@layout/layout_send_receipt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="24dp" />
android:layout_marginTop="32dp"
android:layout_marginBottom="24dp"
android:visibility="gone"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/llBottomButtonContainer"

View file

@ -0,0 +1,137 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/llTotalContainer"
android:layout_width="match_parent"
android:layout_height="64dp"
android:gravity="center_vertical"
android:orientation="vertical">
<LinearLayout
android:id="@+id/llTotal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvTotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/send_total"
android:textSize="20sp"
android:textStyle="bold" />
<TextSwitcher
android:id="@+id/tvTotalValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:inAnimation="@anim/slide_in_right"
android:outAnimation="@android:anim/slide_out_right">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textAllCaps="true"
android:textSize="20sp"
android:textStyle="bold"
tools:text="usd" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textAllCaps="true"
android:textSize="20sp"
android:textStyle="bold"
tools:text="usd" />
</TextSwitcher>
</FrameLayout>
<TextSwitcher
android:id="@+id/tvWillBeSentValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:inAnimation="@anim/slide_in_right"
android:outAnimation="@android:anim/slide_out_right">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="4dp"
android:text="@string/send_total_will_be_sent"
android:textColor="@color/darkGray1" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="4dp"
android:text="@string/send_total_will_be_sent"
android:textColor="@color/darkGray1" />
</TextSwitcher>
</LinearLayout>
<FrameLayout
android:id="@+id/flTotalTokenCrypto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone">
<TextView
android:id="@+id/tvTotalTokenCrypto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/send_total"
android:textColor="@color/darkGray1"
android:textSize="14sp"
android:textStyle="bold" />
<TextSwitcher
android:id="@+id/tvTotalTokenCryptoValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:inAnimation="@anim/slide_in_right"
android:outAnimation="@android:anim/slide_out_right">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textAllCaps="true"
android:textColor="@color/darkGray1"
android:textSize="14sp"
android:textStyle="bold"
tools:text="usd" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textAllCaps="true"
android:textColor="@color/darkGray1"
android:textSize="14sp"
android:textStyle="bold"
tools:text="usd" />
</TextSwitcher>
</FrameLayout>
</LinearLayout>

View file

@ -41,7 +41,7 @@
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAmountToSend"
android:layout_width="match_parent"
android:layout_height="82dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:boxBackgroundColor="@android:color/transparent"
@ -66,12 +66,12 @@
android:id="@+id/tvAmountCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="26dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:inAnimation="@anim/slide_in_up"
android:outAnimation="@anim/slide_out_down"
app:layout_constraintBottom_toBottomOf="@+id/flAmountToSend"
app:layout_constraintEnd_toEndOf="parent">
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
@ -82,7 +82,8 @@
android:textAllCaps="true"
android:textColor="@color/blue"
android:textSize="32sp"
app:drawableEndCompat="@drawable/ic_arrows_up_down" />
app:drawableEndCompat="@drawable/ic_arrows_up_down"
tools:text="USD" />
<TextView
android:layout_width="wrap_content"
@ -112,13 +113,15 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textColor="@color/darkGray1" />
android:textColor="@color/darkGray1"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textColor="@color/darkGray1" />
android:textColor="@color/darkGray1"
android:textSize="16sp" />
</TextSwitcher>

View file

@ -12,7 +12,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/send_fee"
android:textSize="13sp"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@+id/flExpandCollapse"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@ -27,11 +27,10 @@
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/llFeeContainer"
android:id="@+id/llFeeControlsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/flExpandCollapse">
@ -43,6 +42,7 @@
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:translationY="-8dp"
app:selectionRequired="true"
app:singleLine="true"
app:singleSelection="true">
@ -75,9 +75,9 @@
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:text="@string/send_fee_include" />
android:text="@string/send_fee_include"
android:textSize="13sp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/clReceiptContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/tvReceiptAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/send_total_amount"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextSwitcher
android:id="@+id/tvReceiptAmountValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:inAnimation="@anim/slide_in_right"
android:outAnimation="@android:anim/slide_out_right"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textAllCaps="true"
android:textStyle="bold"
tools:text="75.00 usd" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textAllCaps="true"
android:textStyle="bold"
tools:text="75.00 usd" />
</TextSwitcher>
<TextView
android:id="@+id/tvReceiptFee"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/send_total_fee"
android:textColor="@color/darkGray1"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvReceiptAmount" />
<TextSwitcher
android:id="@+id/tvReceiptFeeValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:inAnimation="@anim/slide_in_right"
android:outAnimation="@android:anim/slide_out_right"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/tvReceiptFee">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textAllCaps="true"
android:textColor="@color/darkGray1"
android:textStyle="bold"
tools:text="0.03 usd" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:textAllCaps="true"
android:textColor="@color/darkGray1"
android:textStyle="bold"
tools:text="0.03 usd" />
</TextSwitcher>
<View
android:id="@+id/delimiter"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="10dp"
android:background="@color/separatorGrey2"
app:layout_constraintTop_toBottomOf="@+id/tvReceiptFee"
tools:layout_editor_absoluteX="0dp" />
<include
android:id="@+id/llTotalContainer"
layout="@layout/layout_receipt_total"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/delimiter" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,146 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/tvAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/send_total_amount"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textView9"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/tvAmountCurrency"
app:layout_constraintTop_toTopOf="parent"
tools:text="75.00" />
<TextView
android:id="@+id/tvAmountCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAllCaps="true"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="usd" />
<TextView
android:id="@+id/tvFee"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/send_total_fee"
android:textColor="@color/darkGray1"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvAmount" />
<TextView
android:id="@+id/tvFeeValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:textColor="@color/darkGray1"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/tvFeeCurrency"
app:layout_constraintTop_toTopOf="@+id/tvFeeCurrency"
tools:text="0.003" />
<TextView
android:id="@+id/tvFeeCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAllCaps="true"
android:textColor="@color/darkGray1"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/tvFee"
tools:text="usd" />
<View
android:id="@+id/delimiter"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="10dp"
android:background="@color/separatorGrey2"
app:layout_constraintTop_toBottomOf="@+id/tvFee"
tools:layout_editor_absoluteX="0dp" />
<TextView
android:id="@+id/tvTotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/send_total"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/delimiter" />
<TextView
android:id="@+id/tvTotalValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/tvTotalCurrency"
app:layout_constraintTop_toTopOf="@+id/tvTotalCurrency"
tools:text="75.40" />
<TextView
android:id="@+id/tvTotalCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAllCaps="true"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/tvTotal"
tools:text="usd" />
<TextView
android:id="@+id/tvSentValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:textColor="@color/lightGray4"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/tvSentCurrency"
app:layout_constraintTop_toTopOf="@+id/tvWillBeSent"
tools:text="0.0045544645" />
<TextView
android:id="@+id/tvSentCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:textColor="@color/lightGray4"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/tvWillBeSent"
app:layout_constraintTop_toTopOf="@+id/tvWillBeSent"
tools:text="BTC" />
<TextView
android:id="@+id/tvWillBeSent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/send_total_will_be_sent"
android:textColor="@color/lightGray4"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTotal" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -2,10 +2,13 @@
<string name="app_name" translatable="false">Tangem Tap</string>
<string name="generic_done">Done</string>
<string name="generic_retry">Retry</string>
<string name="generic_and">and</string>
<string name="notification_address_copied">Address was successfully copied</string>
<string name="notification_no_internet">No internet connection</string>
<string name="notification_no_internet_retry">Retry</string>
<string name="generic_done">Done</string>
<string name="notification_no_internet_retry">@string/generic_retry</string>
<string name="home_do_you_have_card">Welcome to Tangem.\nDo you have one of our cards?</string>
<string name="home_button_yes">Yes!</string>
@ -47,6 +50,11 @@
<string name="error_payid_request_failed">PayID request failed</string>
<string name="error_address_invalid_or_unsupported">Address is invalid or unsupported by blockchain</string>
<string name="error_address_same_as_wallet">Address is the same as wallet address</string>
<string name="error_fee_request_failed">Network fee request is failed</string>
<string name="error_fee_greater_than_amount">Fee greater than amount</string>
<string name="error_amount_with_fee_greater_than_balance">Amount with fee greater than balance</string>
<string name="error_insufficient_balance">Insufficient balance</string>
<string name="error_blockchain_internal">Blockchain internal error</string>
<string name="send_title">Send</string>

View file

@ -1,4 +1,5 @@
<resources>
<string name="sign_rough"></string>
</resources>