Updated on 2026-08-14

This commit is contained in:
Tangem 2020-09-06 16:50:14 +03:00
parent a19f530fd8
commit 1d1c641e6b
13 changed files with 295 additions and 108 deletions

View file

@ -61,7 +61,7 @@ dependencies {
implementation 'androidx.core:core-ktx:1.3.1'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
implementation 'com.google.android.material:material:1.2.0'
implementation 'com.google.android.material:material:1.2.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.10'
implementation 'com.tangem:blockchain:1.27.0'

View file

@ -0,0 +1,3 @@
package com.tangem.tap.common.extensions
fun Int.isEven() = this and 1 == 0

View file

@ -1,6 +1,7 @@
package com.tangem.tap.common.qrCodeScan
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@ -9,14 +10,17 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.zxing.Result
import com.otaliastudios.cameraview.CameraView.PERMISSION_REQUEST_CODE
import com.tangem.tap.features.send.redux.AddressPayIdActionUI
import com.tangem.tap.store
import me.dm7.barcodescanner.zxing.ZXingScannerView
/**
[REDACTED_AUTHOR]
*/
class ScanQrCodeActivity : AppCompatActivity(), ZXingScannerView.ResultHandler {
companion object {
val SCAN_QR_REQUEST_CODE = 1001
val SCAN_RESULT = "scanResult"
}
private lateinit var mScannerView: ZXingScannerView
override fun onCreate(state: Bundle?) {
@ -40,7 +44,7 @@ class ScanQrCodeActivity : AppCompatActivity(), ZXingScannerView.ResultHandler {
}
override fun handleResult(result: Result) {
store.dispatch(AddressPayIdActionUI.SetAddressOrPayId(result.text))
setResult(SCAN_QR_REQUEST_CODE, Intent().apply { putExtra(SCAN_RESULT, result.text) })
finish()
}

View file

@ -9,7 +9,7 @@ import timber.log.Timber
val logMiddleware: Middleware<AppState> = { dispatch, appState ->
{ nextDispatch ->
{ action ->
Timber.d("$action")
Timber.d("Dispatch action: $action")
nextDispatch(action)
}
}

View file

@ -0,0 +1,136 @@
package com.tangem.tap.common.text
import android.widget.TextView
import com.tangem.tap.common.extensions.isEven
/**
[REDACTED_AUTHOR]
*/
enum class TruncateType {
START, MIDDLE, END
}
interface Truncate {
fun apply(tv: TextView, text: String, with: String): String
companion object {
fun create(type: TruncateType): Truncate {
return when (type) {
TruncateType.START -> TruncateStart()
TruncateType.MIDDLE -> TruncateMiddle()
TruncateType.END -> TruncateEnd()
}
}
}
}
abstract class BaseTruncate : Truncate {
protected var hasBeenTruncated = false
override fun apply(tv: TextView, text: String, with: String): String {
val roughLength = getRoughFitLength(tv, text, with)
val fittedText = preciseFitting(tv, roughTruncate(text, roughLength), with)
return if (hasBeenTruncated) attachWith(fittedText, with) else fittedText
}
protected fun getRoughFitLength(tv: TextView, text: String, with: String): Int {
val existingSpace = tv.measuredWidth - (tv.paddingStart + tv.paddingEnd)
val textWillTakeSpace = tv.paint.measureText(text)
val overSizeRatio: Float = textWillTakeSpace / existingSpace
val maxLengthOfText = text.length / overSizeRatio
if (text.length <= maxLengthOfText) return text.length
return maxLengthOfText.toInt()
}
protected fun preciseFitting(tv: TextView, text: String, with: String): String {
if (!hasBeenTruncated) return text
val spaceForText = tv.measuredWidth - (tv.paddingStart + tv.paddingEnd)
var fittedText = text
while (tv.paint.measureText(fittedText + with) > spaceForText) {
fittedText = preciseTruncate(fittedText)
}
return fittedText
}
protected abstract fun roughTruncate(text: String, residualLength: Int): String
protected abstract fun preciseTruncate(text: String): String
protected abstract fun attachWith(text: String, with: String): String
}
class TruncateStart : BaseTruncate() {
override fun roughTruncate(text: String, residualLength: Int): String {
if (text.length <= residualLength) return text
hasBeenTruncated = true
return text.substring(residualLength, text.length)
}
override fun preciseTruncate(text: String): String = text.substring(1, text.length)
override fun attachWith(text: String, with: String): String = with + text
}
class TruncateMiddle : BaseTruncate() {
override fun roughTruncate(text: String, residualLength: Int): String {
if (text.length <= residualLength) return text
hasBeenTruncated = true
val halfOfResidualLength = residualLength / 2
val leftSide = text.substring(0, halfOfResidualLength)
val rightSide = text.substring(text.length - halfOfResidualLength, text.length)
return leftSide + rightSide
}
override fun preciseTruncate(text: String): String {
val middlePosition = text.length / 2
return if (text.length.isEven()) {
val leftSide = text.substring(0, middlePosition - 1)
val rightSide = text.substring(middlePosition, text.length)
leftSide + rightSide
} else {
val leftSide = text.substring(0, middlePosition)
val rightSide = text.substring(middlePosition + 1, text.length)
leftSide + rightSide
}
}
override fun attachWith(text: String, with: String): String {
val cuttingPosition = text.length / 2
val leftSide = text.substring(0, cuttingPosition)
val rightSide = text.substring(cuttingPosition, text.length)
return leftSide + with + rightSide
}
}
class TruncateEnd : BaseTruncate() {
override fun roughTruncate(text: String, residualLength: Int): String {
if (text.length <= residualLength) return text
hasBeenTruncated = true
return text.substring(0, residualLength)
}
override fun preciseTruncate(text: String): String = text.substring(0, text.length - 1)
override fun attachWith(text: String, with: String): String = text + with
}
fun TextView.truncateWith(text: String, type: TruncateType, with: String = "..."): String {
val truncate = Truncate.create(type)
return truncate.apply(this, text, with)
}
fun TextView.truncateStartWith(text: String, with: String = "..."): String =
this.truncateWith(text, TruncateType.START, with)
fun TextView.truncateMiddleWith(text: String, with: String = "..."): String =
this.truncateWith(text, TruncateType.MIDDLE, with)
fun TextView.truncateEndWith(text: String, with: String = "..."): String =
this.truncateWith(text, TruncateType.END, with)

View file

@ -44,14 +44,13 @@ private fun handleSendAction(action: Action) {
internal class AddressPayIdHandler {
fun handle(data: String) {
val walletManager = store.state.globalState.walletManager ?: return
if (data == store.state.sendState.addressPayIDState.etFieldValue) return
if (isPayId(data)) {
verifyPayId(data, walletManager)
store.dispatch(ProcessedButNotVerifiedAddressPayId(data))
} else {
val supposedAddress = extractAddressFromShareUri(data)
store.dispatch(PayIdVerification.Failed(supposedAddress, FailReason.IS_NOT_PAY_ID))
store.dispatch(ProcessedButNotVerifiedAddressPayId(supposedAddress))
verifyAddress(walletManager, supposedAddress)
}
}

View file

@ -1,6 +1,9 @@
package com.tangem.tap.features.send.redux
import android.view.View
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.FeeActionUI.*
import org.rekotlin.Action
import org.rekotlin.StateType
@ -11,7 +14,12 @@ import timber.log.Timber
*/
class SendReducer {
companion object {
fun reduce(action: Action, sendState: SendState): SendState = internalReduce(action, sendState)
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
}
}
}
@ -20,66 +28,53 @@ private fun internalReduce(incomingAction: Action, sendState: SendState): SendSt
val action = incomingAction as? SendScreenAction ?: return sendState
return when (action) {
is FeeActionUI -> handleFeeLayoutAction(action, sendState, sendState.feeLayoutState)
// is SetAddressOrPayId -> handleAddressPayIdUIAction(action, sendState, sendState.addressPayIDState)
is AddressPayIdActionUI -> handleAddressPayIdActionUI(action, sendState, sendState.addressPayIDState)
is AddressPayIdVerifyAction -> handleAddressPayIdAction(action, sendState, sendState.addressPayIDState)
is FeeActionUI -> handleFeeActionUI(action, sendState, sendState.feeLayoutState)
else -> sendState
}
}
fun handleAddressPayIdActionUI(
action: AddressPayIdActionUI,
sendState: SendState,
state: AddressPayIDState
): SendState {
val result = when (action) {
is SetAddressOrPayId -> 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: AddressPayIDState? = when (action) {
is AddressPayIdVerifyAction.PayIdVerification.Success -> {
state.copyPayIdWalletAddress(action.payId, action.payIdWalletAddress)
}
is AddressPayIdVerifyAction.PayIdVerification.Failed -> {
state.copyPaiIdError(action.payId, action.reason)
}
is AddressPayIdVerifyAction.AddressVerification.Success -> {
state.copyWalletAddress(action.address)
}
is AddressPayIdVerifyAction.AddressVerification.Failed -> {
state.copyError(action.address, action.reason)
}
is AddressPayIdVerifyAction.ProcessedButNotVerifiedAddressPayId -> {
state.copy(etFieldValue = action.data)
}
val result = when (action) {
is PayIdVerification.Success -> state.copyPayIdWalletAddress(action.payId, action.payIdWalletAddress)
is PayIdVerification.Failed -> state.copyPaiIdError(action.payId, action.reason)
is AddressVerification.Success -> state.copyWalletAddress(action.address)
is AddressVerification.Failed -> state.copyError(action.address, action.reason)
}
return if (result == null) {
Timber.e("AddressPayIDState didn't modified.")
sendState
} else {
updateLastState(sendState.copy(addressPayIDState = result), result)
}
return updateLastState(sendState.copy(addressPayIDState = result), result)
}
private fun handleFeeLayoutAction(action: FeeActionUI, sendState: SendState, state: FeeLayoutState): SendState {
return when (action) {
private fun handleFeeActionUI(action: FeeActionUI, sendState: SendState, state: FeeLayoutState): SendState {
val result = when (action) {
is ToggleFeeLayoutVisibility -> {
val visibility = if (state.visibility == View.VISIBLE) View.GONE
else View.VISIBLE
val result = state.copy(visibility = visibility)
updateLastState(sendState.copy(feeLayoutState = result), result)
}
is ChangeSelectedFee -> {
val result = state.copy(selectedFeeId = action.id)
updateLastState(sendState.copy(feeLayoutState = result), result)
}
is ChangeIncludeFee -> {
val result = state.copy(includeFeeIsChecked = action.isChecked)
updateLastState(sendState.copy(feeLayoutState = result), result)
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 {
val sendState = sendState.copy(lastChangedStateType = lastChangedState)
Timber.d("$sendState")
return sendState
}
private fun updateLastState(sendState: SendState, lastChangedState: StateType): SendState =
sendState.copy(lastChangedStateType = lastChangedState)

View file

@ -19,6 +19,8 @@ sealed class FeeActionUI : SendScreenActionUI {
// shortness AddressOrPayId = APid
sealed class AddressPayIdActionUI : SendScreenActionUI {
data class SetAddressOrPayId(val data: String) : AddressPayIdActionUI()
data class SetTruncateHandler(val handler: (String) -> String) : AddressPayIdActionUI()
data class TruncateOrRestore(val truncate: Boolean) : AddressPayIdActionUI()
}
sealed class AddressPayIdVerifyAction : SendScreenAction {
@ -32,8 +34,6 @@ sealed class AddressPayIdVerifyAction : SendScreenAction {
ADDRESS_SAME_AS_WALLET
}
data class ProcessedButNotVerifiedAddressPayId(val data: String) : AddressPayIdVerifyAction()
sealed class PayIdVerification : AddressPayIdVerifyAction() {
data class Failed(val payId: String, val reason: FailReason) : PayIdVerification()
data class Success(val payId: String, val payIdWalletAddress: String) : PayIdVerification()

View file

@ -19,25 +19,57 @@ 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 != etFieldValue
fun isPayIdState(): Boolean = walletAddress != null && walletAddress != normalFieldValue
fun copyWalletAddress(address: String): AddressPayIDState {
return this.copy(etFieldValue = address, walletAddress = address, error = null)
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 {
return this.copy(etFieldValue = address, error = error, walletAddress = null)
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 {
return this.copy(etFieldValue = payId, walletAddress = address, error = null)
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 {
return this.copy(etFieldValue = payId, error = error, walletAddress = null)
val truncated = truncateHandler?.invoke(payId) ?: payId
return this.copy(
etFieldValue = payId,
normalFieldValue = payId,
truncatedFieldValue = truncated,
error = error,
walletAddress = null
)
}
}

View file

@ -7,12 +7,12 @@ import android.widget.EditText
import androidx.core.widget.addTextChangedListener
import com.tangem.tap.common.extensions.getFromClipboard
import com.tangem.tap.common.qrCodeScan.ScanQrCodeActivity
import com.tangem.tap.common.text.truncateMiddleWith
import com.tangem.tap.features.send.BaseStoreFragment
import com.tangem.tap.features.send.redux.AddressPayIdActionUI.SetAddressOrPayId
import com.tangem.tap.features.send.redux.AddressPayIdActionUI.*
import com.tangem.tap.features.send.redux.FeeActionUI.*
import com.tangem.tap.features.send.redux.ReleaseSendState
import com.tangem.tap.features.send.ui.stateSubscribers.SendStateSubscriber
import com.tangem.tap.features.send.ui.stateSubscribers.WalletStateSubscriber
import com.tangem.tap.mainScope
import com.tangem.tap.store
import com.tangem.wallet.R
@ -24,17 +24,53 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
/**
[REDACTED_AUTHOR]
*/
class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
private val sendSubscriber = SendStateSubscriber(this)
private val walletSubscriber = WalletStateSubscriber(this)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupAddressOrPayIdLayout()
setupFeeLayout()
}
private fun setupAddressOrPayIdLayout() {
store.dispatch(SetTruncateHandler { etAddressOrPayId.truncateMiddleWith(it, " *** ") })
etAddressOrPayId.setOnFocusChangeListener { v, hasFocus ->
store.dispatch(TruncateOrRestore(!hasFocus))
}
etAddressOrPayId.inputedTextAsFlow()
.debounce(400)
.filter { store.state.sendState.addressPayIDState.etFieldValue != it }
.onEach { store.dispatch(SetAddressOrPayId(it)) }
.launchIn(mainScope)
imvPaste.setOnClickListener {
store.dispatch(SetAddressOrPayId(requireContext().getFromClipboard()?.toString() ?: ""))
store.dispatch(TruncateOrRestore(!etAddressOrPayId.isFocused))
}
imvQrCode.setOnClickListener {
startActivityForResult(
Intent(requireContext(), ScanQrCodeActivity::class.java),
ScanQrCodeActivity.SCAN_QR_REQUEST_CODE
)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != ScanQrCodeActivity.SCAN_QR_REQUEST_CODE) return
val scannedCode = data?.getStringExtra(ScanQrCodeActivity.SCAN_RESULT) ?: ""
store.dispatch(SetAddressOrPayId(scannedCode))
store.dispatch(TruncateOrRestore(!etAddressOrPayId.isFocused))
}
private fun setupFeeLayout() {
flExpandCollapse.setOnClickListener {
store.dispatch(ToggleFeeLayoutVisibility)
}
@ -44,30 +80,13 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
swIncludeFee.setOnCheckedChangeListener { btn, isChecked ->
store.dispatch(ChangeIncludeFee(isChecked))
}
etAddressOrPayId.inputedTextAsFlow()
.debounce(400)
.filter { store.state.sendState.addressPayIDState.etFieldValue != it }
.onEach { store.dispatch(SetAddressOrPayId(it)) }
.launchIn(mainScope)
imvPaste.setOnClickListener {
store.dispatch(SetAddressOrPayId(requireContext().getFromClipboard()?.toString() ?: ""))
}
imvQrCode.setOnClickListener {
requireActivity().startActivity(Intent(requireContext(), ScanQrCodeActivity::class.java))
}
}
override fun subscribeToStore() {
store.subscribe(walletSubscriber) { appState ->
appState.skipRepeats { oldState, newState -> false }.select { it.walletState }
}
store.subscribe(sendSubscriber) { appState ->
appState.skipRepeats { oldState, newState -> false }.select { it.sendState }
appState.skipRepeats { oldState, newState -> oldState == newState }.select { it.sendState }
}
storeSubscribersList.add(walletSubscriber)
storeSubscribersList.add(sendSubscriber)
}

View file

@ -40,14 +40,25 @@ class SendStateSubscriber(fragment: Fragment) : FragmentStateSubscriber<SendStat
else context.getString(resId)
}
val parsedError = parseError(fg.tilAddressOrPayId.context, state.error)
fg.tilAddressOrPayId.error = parsedError
fg.tilAddressOrPayId.isErrorEnabled = parsedError != null
fg.tilAddressOrPayId.helperText = state.walletAddress
fg.tilAddressOrPayId.isHelperTextEnabled = state.isPayIdState() && parsedError == null
if (fg.etAddressOrPayId.text?.toString() != state.etFieldValue) {
fg.etAddressOrPayId.setText(state.etFieldValue)
if (fg.etAddressOrPayId.isFocused) fg.etAddressOrPayId.setSelection(state.etFieldValue?.length ?: 0)
val et = fg.etAddressOrPayId
val til = fg.tilAddressOrPayId
val parsedError = parseError(til.context, state.error)
til.error = parsedError
til.isErrorEnabled = parsedError != null
til.helperText = state.walletAddress
til.isHelperTextEnabled = state.isPayIdState() && parsedError == null
// prevent cycling
if (state.etFieldValue == null || et.text?.toString() == state.etFieldValue) return
// prevent cursor jumping while editing
if (et.isFocused) {
val prevSelection = et.selectionStart
et.setText(state.etFieldValue)
et.setSelection(prevSelection)
} else {
et.setText(state.etFieldValue)
}
}

View file

@ -1,13 +0,0 @@
package com.tangem.tap.features.send.ui.stateSubscribers
import androidx.fragment.app.Fragment
import com.tangem.tap.features.wallet.redux.WalletState
/**
[REDACTED_AUTHOR]
*/
class WalletStateSubscriber(fragment: Fragment) : FragmentStateSubscriber<WalletState>(fragment) {
override fun updateWithNewState(fg: Fragment, state: WalletState) {
}
}

View file

@ -2,17 +2,18 @@
<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:paddingStart="16dp"
android:paddingEnd="16dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAddressOrPayId"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/send_address_or_payid"
app:boxBackgroundColor="@android:color/transparent"
app:errorIconDrawable="@null"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
@ -23,7 +24,7 @@
android:layout_height="wrap_content"
android:ellipsize="middle"
android:paddingStart="0dp"
android:paddingEnd="96dp"
android:paddingEnd="98dp"
android:singleLine="true"
android:textSize="16sp"
tools:text="139mrsJgyWnJ **** y9BV" />