send: send screen is done.

This commit is contained in:
Kevin Gorham 2019-02-21 01:37:09 -05:00 committed by Kevin Gorham
parent 524b89a11a
commit a7c7954823
17 changed files with 447 additions and 216 deletions

View File

@ -2,9 +2,12 @@ package cash.z.android.wallet.extention
import android.text.Editable
import android.text.TextWatcher
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import androidx.core.content.getSystemService
fun EditText.afterTextChanged(block: (String) -> Unit) {
inline fun EditText.afterTextChanged(crossinline block: (String) -> Unit) {
this.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
block.invoke(s.toString())
@ -13,4 +16,30 @@ fun EditText.afterTextChanged(block: (String) -> Unit) {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
}
inline fun EditText.doOnDone(crossinline block: (String) -> Unit) {
setOnEditorActionListener { v, actionId, _ ->
return@setOnEditorActionListener if ((actionId == EditorInfo.IME_ACTION_DONE)) {
v.clearFocus()
// v.clearComposingText()
v.context.getSystemService<InputMethodManager>()
?.hideSoftInputFromWindow(v.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
block(this.text.toString())
true
} else {
false
}
}
}
inline fun EditText.doOnFocusLost(crossinline block: (String) -> Unit) {
setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) block(this.text.toString())
}
}
inline fun EditText.doOnDoneOrFocusLost(crossinline block: (String) -> Unit) {
doOnDone(block)
doOnFocusLost(block)
}

View File

@ -53,6 +53,11 @@ class ReceiveFragment : BaseFragment() {
text_address_part_8
)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
mainActivity.setToolbarShown(true)
}
override fun onResume() {
super.onResume()

View File

@ -227,21 +227,26 @@ class ScanFragment : BaseFragment() {
}
override fun onImageAvailable(image: Image) {
System.err.println("camoorah : onImageAvailable: $image width: ${image.width} height: ${image.height}")
var firebaseImage = FirebaseVisionImage.fromMediaImage(image, getRotationCompensation(cameraId, mainActivity))
barcodeDetector
.detectInImage(firebaseImage)
.addOnSuccessListener { results ->
if (results.isNotEmpty()) {
val barcode = results[0]
val value = barcode.rawValue
onScanSuccess(value!!)
// TODO: highlight the barcode
var bounds = barcode.boundingBox
var corners = barcode.cornerPoints
binding.cameraView.setBarcode(barcode)
try {
System.err.println("camoorah : onImageAvailable: $image width: ${image.width} height: ${image.height}")
var firebaseImage =
FirebaseVisionImage.fromMediaImage(image, getRotationCompensation(cameraId, mainActivity))
barcodeDetector
.detectInImage(firebaseImage)
.addOnSuccessListener { results ->
if (results.isNotEmpty()) {
val barcode = results[0]
val value = barcode.rawValue
onScanSuccess(value!!)
// TODO: highlight the barcode
var bounds = barcode.boundingBox
var corners = barcode.cornerPoints
binding.cameraView.setBarcode(barcode)
}
}
}
} catch (t: Throwable) {
System.err.println("camoorah : error while processing onImageAvailable: $t\n\tcaused by: ${t.cause}")
}
}
}

View File

@ -23,15 +23,12 @@ import cash.z.android.wallet.R
import cash.z.android.wallet.databinding.FragmentSendBinding
import cash.z.android.wallet.extention.*
import cash.z.android.wallet.sample.SampleProperties
import cash.z.android.wallet.ui.activity.MainActivity
import cash.z.android.wallet.ui.presenter.SendPresenter
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.safelyConvertToBigDecimal
import dagger.Module
import dagger.android.ContributesAndroidInjector
import kotlinx.coroutines.launch
import java.text.DecimalFormat
import kotlin.math.absoluteValue
/**
* Fragment for sending Zcash.
@ -39,12 +36,12 @@ import kotlin.math.absoluteValue
*/
class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.BarcodeCallback {
lateinit var sendPresenter: SendPresenter
lateinit var binding: FragmentSendBinding
private val zec = R.string.zec_abbreviation.toAppString()
private val usd = R.string.usd_abbreviation.toAppString()
lateinit var sendPresenter: SendPresenter
lateinit var binding: FragmentSendBinding
//
// Lifecycle
@ -74,6 +71,7 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
mainActivity.setToolbarShown(true)
sendPresenter = SendPresenter(this, mainActivity.synchronizer)
}
@ -94,7 +92,7 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
// SendView Implementation
//
override fun submit() {
override fun exit() {
mainActivity.navController.navigate(R.id.nav_home_fragment)
}
@ -131,6 +129,10 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
binding.textZecValueAvailable.text = availableTextSpan
}
override fun setSendEnabled(isEnabled: Boolean) {
binding.buttonSendZec.isEnabled = isEnabled
}
//
// ScanFragment.BarcodeCallback implemenation
@ -139,7 +141,7 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
override fun onBarcodeScanned(value: String) {
exitScanMode()
binding.inputZcashAddress.setText(value)
validateAddressInput()
sendPresenter.inputAddressUpdated(value)
}
@ -151,29 +153,48 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
* Initialize view logic only. Click listeners, text change handlers and tooltips.
*/
private fun init() {
/* Presenter calls */
binding.imageSwapCurrency.setOnClickListener {
sendPresenter.toggleCurrency()
}
/* Init - Text Input */
binding.textValueHeader.apply {
afterTextChanged {
sendPresenter.headerUpdating(it)
}
setSelectAllOnFocus(true)
afterTextChanged { if (it.isNotEmpty()) sendPresenter.inputHeaderUpdating(it) }
doOnDoneOrFocusLost { sendPresenter.inputHeaderUpdated(it) }
}
binding.buttonSendZec.setOnClickListener {
sendPresenter.sendPressed()
binding.inputZcashAddress.apply {
afterTextChanged { if (it.isNotEmpty()) sendPresenter.inputAddressUpdating(it) }
doOnDoneOrFocusLost { sendPresenter.inputAddressUpdated(it) }
}
binding.textAreaMemo.apply {
afterTextChanged {
if (it.isNotEmpty()) sendPresenter.inputMemoUpdating(it)
binding.textMemoCharCount.text = "${text.length} / ${resources.getInteger(R.integer.memo_max_length)}"
}
doOnDoneOrFocusLost { sendPresenter.inputMemoUpdated(it) }
}
/* Init - Taps */
binding.imageSwapCurrency.setOnClickListener {
// validate the amount before we toggle (or else we lose their uncommitted change)
sendPresenter.inputHeaderUpdated(binding.textValueHeader.text.toString())
sendPresenter.inputToggleCurrency()
}
binding.buttonSendZec.setOnClickListener{
exitScanMode()
sendPresenter.inputSendPressed()
}
// allow background taps to dismiss the keyboard and clear focus
binding.contentFragmentSend.setOnClickListener {
sendPresenter.invalidate()
hideKeyboard()
}
/* Non-Presenter calls (UI-only logic) */
binding.textAreaMemo.afterTextChanged {
binding.textMemoCharCount.text =
"${binding.textAreaMemo.text.length} / ${resources.getInteger(R.integer.memo_max_length)}"
}
binding.imageScanQr.apply {
TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_scan_qr))
}
@ -187,23 +208,9 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
visibility = View.GONE
}
}
binding.dialogSendBackground.setOnClickListener {
hideSendDialog()
}
binding.dialogSubmitButton.setOnClickListener {
onSendZec()
}
binding.dialogSendBackground.setOnClickListener { hideSendDialog() }
binding.dialogSubmitButton.setOnClickListener { onSendZec() }
binding.imageScanQr.setOnClickListener(::onScanQrCode)
// allow background taps to dismiss the keyboard and clear focus
binding.contentFragmentSend.setOnClickListener {
it?.findFocus()?.clearFocus()
validateUserInput()
hideKeyboard()
}
binding.buttonSendZec.text = getString(R.string.send_button_label, zec)
setSendEnabled(false)
}
@ -243,15 +250,19 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
}
}
// TODO: possibly move this behavior to only live in the debug build. Perhaps with a viewholder that I just delegate to. Then inject the holder here.
// TODO: possibly move this behavior to only live in the debug build. Perhaps with a viewholder that I just delegate to. Then inject the holder in this class with production verstion getting an empty implementation that just hides the icon.
private fun onPasteShortcutAddress(view: View) {
view.context.alert(R.string.send_alert_shortcut_clicked) {
binding.inputZcashAddress.setText(SampleProperties.wallet.defaultSendAddress)
validateAddressInput()
val address = SampleProperties.wallet.defaultSendAddress
binding.inputZcashAddress.setText(address)
sendPresenter.inputAddressUpdated(address)
hideKeyboard()
}
}
/**
* Called after confirmation dialog is affirmed. Begins the process of actually sending ZEC.
*/
private fun onSendZec() {
setSendEnabled(false)
sendPresenter.sendFunds()
@ -271,6 +282,7 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
private fun hideKeyboard() {
mainActivity.getSystemService<InputMethodManager>()
?.hideSoftInputFromWindow(view?.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
checkAllInput()
}
private fun hideSendDialog() {
@ -278,12 +290,28 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
binding.groupDialogSend.visibility = View.GONE
}
// note: be careful calling this with `true` that should only happen when all conditions have been validated
private fun setSendEnabled(isEnabled: Boolean) {
binding.buttonSendZec.isEnabled = isEnabled
private fun setAddressLineColor(@ColorRes colorRes: Int = R.color.zcashBlack_12) {
DrawableCompat.setTint(
binding.inputZcashAddress.background,
ContextCompat.getColor(mainActivity, colorRes)
)
}
private fun setAddressError(message: String?) {
/* Error handling */
override fun setAmountError(message: String?) {
if (message == null) {
binding.textValueError.visibility = View.GONE
binding.textValueError.text = null
} else {
binding.textValueError.text = message
binding.textValueError.visibility = View.VISIBLE
setSendEnabled(false)
}
}
override fun setAddressError(message: String?) {
if (message == null) {
setAddressLineColor()
binding.textAddressError.text = null
@ -296,74 +324,36 @@ class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.Barcod
}
}
private fun setAddressLineColor(@ColorRes colorRes: Int = R.color.zcashBlack_12) {
DrawableCompat.setTint(
binding.inputZcashAddress.background,
ContextCompat.getColor(mainActivity, colorRes)
)
}
private fun setAmountError(isError: Boolean) {
val color = if (isError) R.color.zcashRed else R.color.text_dark
binding.textAmountBackground.setTextColor(color.toAppColor())
}
//
// Validation
//
override fun validateUserInput(): Boolean {
val allValid = validateAddressInput() && validateAmountInput() && validateMemo()
setSendEnabled(allValid)
return allValid
}
/**
* Validate the memo input and update presenter when valid.
*
* @return true when the memo is valid
*/
private fun validateMemo(): Boolean {
val memo = binding.textAreaMemo.text.toString()
return memo.all { it.isLetterOrDigit() }.also { if (it) sendPresenter.memoValidated(memo) }
}
/**
* Validate the address input and update presenter when valid.
*
* @return true when the address is valid
*/
private fun validateAddressInput(): Boolean {
var isValid = false
val address = binding.inputZcashAddress.text.toString()
if (address.isNotEmpty() && address.length < R.integer.z_address_min_length.toAppInt()) setAddressError(R.string.send_error_address_too_short.toAppString())
else if (address.any { !it.isLetterOrDigit() }) setAddressError(R.string.send_error_address_invalid_char.toAppString())
else setAddressError(null).also { isValid = true; sendPresenter.addressValidated(address) }
return isValid
}
/**
* Validate the amount input and update the presenter when valid.
*
* @return true when the amount is valid
*/
private fun validateAmountInput(): Boolean {
return try {
val amount = binding.textValueHeader.text.toString().safelyConvertToBigDecimal()!!
sendPresenter.headerValidated(amount)
setAmountError(false)
true
} catch (t: Throwable) {
Toaster.short("Invalid ZEC or USD value")
override fun setMemoError(message: String?) {
val validColor = R.color.zcashBlack_12.toAppColor()
val errorColor = R.color.zcashRed.toAppColor()
if (message == null) {
binding.dividerMemo.setBackgroundColor(validColor)
binding.textMemoCharCount.setTextColor(validColor)
binding.textAreaMemo.setTextColor(R.color.text_dark.toAppColor())
} else {
binding.dividerMemo.setBackgroundColor(errorColor)
binding.textMemoCharCount.setTextColor(errorColor)
binding.textAreaMemo.setTextColor(errorColor)
setSendEnabled(false)
setAmountError(true)
false
}
}
/**
* Validate all input. This is essentially the same as extracting a model out of the view and validating it with the
* presenter. Basically, this needs to happen anytime something is edited, in order to try and enable Send. Right
* now this method is called 1) any time the model is updated with valid input, 2) anytime the keyboard is hidden,
* and 3) anytime send is pressed. It also triggers the only logic that can set "requiresValidation" to false.
*/
override fun checkAllInput(): Boolean {
with(binding) {
return sendPresenter.validateAll(
headerValue = textValueHeader.text.toString(),
toAddress = inputZcashAddress.text.toString(),
memo = textAreaMemo.text.toString()
)
}
}
// TODO: come back to this test code later and fix the shared element transitions

View File

@ -1,10 +1,13 @@
package cash.z.android.wallet.ui.presenter
import android.util.Log
import cash.z.android.wallet.R
import cash.z.android.wallet.extention.toAppInt
import cash.z.android.wallet.extention.toAppString
import cash.z.android.wallet.sample.SampleProperties
import cash.z.android.wallet.ui.presenter.Presenter.PresenterView
import cash.z.wallet.sdk.data.Synchronizer
import cash.z.wallet.sdk.data.twig
import cash.z.wallet.sdk.data.Twig
import cash.z.wallet.sdk.ext.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.GlobalScope
@ -24,38 +27,57 @@ class SendPresenter(
fun setHeaderValue(usdString: String)
fun setSubheaderValue(usdString: String, isUsdSelected: Boolean)
fun showSendDialog(zecString: String, usdString: String, toAddress: String, hasMemo: Boolean)
fun validateUserInput(): Boolean
fun submit()
fun exit()
// error handling
fun setAmountError(message: String?)
fun setAddressError(message: String?)
fun setMemoError(message: String?)
fun setSendEnabled(isEnabled: Boolean)
fun checkAllInput(): Boolean
}
/**
* We require the user to send more than this amount. Right now, we just use the miner's fee as a minimum but other
* lower bounds may also be useful for validation.
*/
private val minimumZatoshiAllowed = 10_000L
private var balanceJob: Job? = null
private var requiresValidation = true
var sendUiModel = SendUiModel()
// TODO: find the best set of characters here. Possibly add something to the rust layer to help with this.
private val validMemoChars = " \t\n\r.?!,\"':;-_=+@#%*"
//
// LifeCycle
//
override suspend fun start() {
Log.e("@TWIG-v", "sendPresenter starting!")
// set the currency to zec and update the view, intializing everything to zero
toggleCurrency()
Twig.sprout("SendPresenter")
twig("sendPresenter starting!")
// set the currency to zec and update the view, initializing everything to zero
inputToggleCurrency()
with(view) {
balanceJob = launchBalanceBinder(synchronizer.balance())
}
}
override fun stop() {
Log.e("@TWIG-v", "sendPresenter stopping!")
twig("sendPresenter stopping!")
Twig.clip("SendPresenter")
balanceJob?.cancel()?.also { balanceJob = null }
}
fun CoroutineScope.launchBalanceBinder(channel: ReceiveChannel<Long>) = launch {
Log.e("@TWIG-v", "send balance binder starting!")
twig("send balance binder starting!")
for (new in channel) {
Log.e("@TWIG-v", "send polled a balance item")
twig("send polled a balance item")
bind(new)
}
Log.e("@TWIG-v", "send balance binder exiting!")
twig("send balance binder exiting!")
}
@ -67,31 +89,38 @@ class SendPresenter(
//TODO: prehaps grab the activity scope or let the sycnchronizer have scope and make that function not suspend
// also, we need to handle cancellations. So yeah, definitely do this differently
GlobalScope.launch {
synchronizer.sendToAddress(sendUiModel.zecValue!!, sendUiModel.toAddress)
synchronizer.sendToAddress(sendUiModel.zatoshiValue!!, sendUiModel.toAddress)
}
view.submit()
view.exit()
}
//
// User Input
//
/**
* Called when the user has tapped on the button for toggling currency, swapping zec for usd
*/
fun toggleCurrency() {
view.validateUserInput()
fun inputToggleCurrency() {
// tricky: this is not really a model update, instead it is a byproduct of using `isUsdSelected` for the
// currency instead of strong types. There are several todo's to fix that. if we update the model here then
// the UI will think the user took action and display errors prematurely.
sendUiModel = sendUiModel.copy(isUsdSelected = !sendUiModel.isUsdSelected)
with(sendUiModel) {
view.setHeaders(
isUsdSelected = isUsdSelected,
headerString = if (isUsdSelected) usdValue.toUsdString() else zecValue.convertZatoshiToZecString(),
subheaderString = if (isUsdSelected) zecValue.convertZatoshiToZecString() else usdValue.toUsdString()
headerString = if (isUsdSelected) usdValue.toUsdString() else zatoshiValue.convertZatoshiToZecString(),
subheaderString = if (isUsdSelected) zatoshiValue.convertZatoshiToZecString() else usdValue.toUsdString()
)
}
}
/**
* As the user is typing the header string, update the subheader string. Do not modify our own internal model yet.
* Internal model is only updated after [headerValidated] is called.
* Internal model is only modified after [headerUpdated] is called (with valid data).
*/
fun headerUpdating(headerValue: String) {
fun inputHeaderUpdating(headerValue: String) {
headerValue.safelyConvertToBigDecimal()?.let { headerValueAsDecimal ->
val subheaderValue = headerValueAsDecimal.convertCurrency(SampleProperties.USD_PER_ZEC, sendUiModel.isUsdSelected)
@ -102,49 +131,77 @@ class SendPresenter(
}
}
fun sendPressed() {
with(sendUiModel) {
view.showSendDialog(
zecString = zecValue.convertZatoshiToZecString(),
usdString = usdValue.toUsdString(),
toAddress = toAddress,
hasMemo = !memo.isBlank()
)
/**
* As the user updates the address, update the error that gets displayed in real-time
*
* @param addressValue the address that the user has typed, so far
*/
fun inputAddressUpdating(addressValue: String) {
validateAddress(addressValue, true)
}
/**
* As the user updates the memo, update the error that gets displayed in real-time
*
* @param memoValue the memo that the user has typed, so far
*/
fun inputMemoUpdating(memoValue: String) {
// treat the memo a little differently because it is more likely for the user to go back and edit invalid chars
// and we want the send button to be active the moment that happens
if(validateMemo(memoValue)) {
updateModel(sendUiModel.copy(memo = memoValue))
}
}
fun headerValidated(amount: BigDecimal) {
/**
* Called when the user has completed their update to the header value, typically on focus change.
*/
fun inputHeaderUpdated(amountString: String) {
if (!validateAmount(amountString)) return
// either USD or ZEC -- TODO: use strong typing (and polymorphism) instead of isUsdSelected checks
val amount = amountString.safelyConvertToBigDecimal()!! // we've already validated this as not null and it's immutable
with(sendUiModel) {
if (isUsdSelected) {
// amount represents USD
val headerString = amount.toUsdString()
val usdValue = amount
val zecValue = amount.convertUsdToZec(SampleProperties.USD_PER_ZEC)
val subheaderString = zecValue.toZecString()
sendUiModel = sendUiModel.copy(zecValue = zecValue.convertZecToZatoshi(), usdValue = usdValue)
val zatoshiValue = amount.convertUsdToZec(SampleProperties.USD_PER_ZEC).convertZecToZatoshi()
val subheaderString = amount.convertUsdToZec(SampleProperties.USD_PER_ZEC).toUsdString()
updateModel(sendUiModel.copy(zatoshiValue = zatoshiValue, usdValue = amount))
view.setHeaders(sendUiModel.isUsdSelected, headerString, subheaderString)
} else {
// amount represents ZEC
val headerString = amount.toZecString()
val zecValue = amount
val usdValue = amount.convertZecToUsd(SampleProperties.USD_PER_ZEC)
val subheaderString = usdValue.toUsdString()
sendUiModel = sendUiModel.copy(zecValue = zecValue.convertZecToZatoshi(), usdValue = usdValue)
println("calling setHeaders with $headerString $subheaderString")
updateModel(sendUiModel.copy(zatoshiValue = amount.convertZecToZatoshi(), usdValue = usdValue))
twig("calling setHeaders with $headerString $subheaderString")
view.setHeaders(sendUiModel.isUsdSelected, headerString, subheaderString)
}
}
}
fun addressValidated(address: String) {
sendUiModel = sendUiModel.copy(toAddress = address)
fun inputAddressUpdated(newAddress: String) {
if (!validateAddress(newAddress)) return
updateModel(sendUiModel.copy(toAddress = newAddress))
}
/**
* After the user has typed a memo, validated by the UI, then update the model.
*
* assert: this method is only called after the memo input has been validated by the UI
*/
fun memoValidated(sanitizedValue: String) {
sendUiModel = sendUiModel.copy(memo = sanitizedValue)
fun inputMemoUpdated(newMemo: String) {
if (!validateMemo(newMemo)) return
updateModel(sendUiModel.copy(memo = newMemo))
}
fun inputSendPressed() {
if (requiresValidation && !view.checkAllInput()) return
with(sendUiModel) {
view.showSendDialog(
zecString = zatoshiValue.convertZatoshiToZecString(),
usdString = usdValue.toUsdString(),
toAddress = toAddress,
hasMemo = !memo.isBlank()
)
}
}
fun bind(newZecBalance: Long) {
@ -154,9 +211,119 @@ class SendPresenter(
}
}
fun updateModel(newModel: SendUiModel) {
sendUiModel = newModel.apply { hasBeenUpdated = true }
// now that we have new data, check and see if we can clear errors and re-enable the send button
if (requiresValidation) view.checkAllInput()
}
//
// Validation
//
/**
* Called after any user interaction. This is a potential time that errors should be shown, but only if data has
* already been entered. The view should call this method on focus change.
*/
fun invalidate() {
requiresValidation = true
}
/**
* Validates the given memo, ensuring that it does not contain unsupported characters. For now, we're very
* restrictive until we define more clear requirements for the values that can safely be entered in this field
* without introducing security risks.
*
* @param memo the memo to consider for validation
*
* @return true when the memo contains valid characters, which includes being blank
*/
private fun validateMemo(memo: String): Boolean {
return if (memo.all { it.isLetterOrDigit() || it in validMemoChars }) {
view.setMemoError(null)
true
} else {
view.setMemoError("Only letters and numbers are allowed in memo at this time")
requiresValidation = true
false
}
}
/**
* Validates the given address
*
* @param toAddress the address to consider for validation
* @param ignoreLength whether to ignore the length while validating, this is helpful when the user is still
* actively typing the address
*/
private fun validateAddress(toAddress: String, ignoreLength: Boolean = false): Boolean {
// TODO: later expose a method in the synchronizer for validating addresses.
// Right now it's not available so we do it ourselves
return if (!ignoreLength && sendUiModel.hasBeenUpdated && toAddress.length < 20) {// arbitrary length for now
view.setAddressError(R.string.send_error_address_too_short.toAppString())
requiresValidation = true
false
} else if (!toAddress.startsWith("zt") && !toAddress.startsWith("zs")) {
view.setAddressError(R.string.send_error_address_invalid_contents.toAppString())
requiresValidation = true
false
} else if (toAddress.any { !it.isLetterOrDigit() }) {
view.setAddressError(R.string.send_error_address_invalid_char.toAppString())
requiresValidation = true
false
} else {
view.setAddressError(null)
true
}
}
/**
* Validates the given amount, calling the related `showError` methods on the view, when appropriate
*
* @param amount the amount to consider for validation, for now this can be either USD or ZEC. In the future we will
* will separate those into types.
*
* @return true when the given amount is valid and all errors have been cleared on the view
*/
private fun validateAmount(amountString: String): Boolean {
if (!sendUiModel.hasBeenUpdated) return true // don't mark zero as bad until the model has been updated
var amount = amountString.safelyConvertToBigDecimal()
// no need to convert when we know it's null
return if (amount == null ) {
validateZatoshiAmount(null)
} else {
val zecAmount =
if (sendUiModel.isUsdSelected) amount.convertUsdToZec(SampleProperties.USD_PER_ZEC) else amount
validateZatoshiAmount(zecAmount.convertZecToZatoshi())
}
}
private fun validateZatoshiAmount(zatoshiValue: Long?): Boolean {
return if (zatoshiValue == null || zatoshiValue <= minimumZatoshiAllowed) {
view.setAmountError("Please specify a larger amount")
requiresValidation = true
false
} else {
view.setAmountError(null)
true
}
}
fun validateAll(headerValue: String, toAddress: String, memo: String): Boolean {
val isValid = validateAmount(headerValue)
&& validateAddress(toAddress)
&& validateMemo(memo)
requiresValidation = !isValid
view.setSendEnabled(isValid)
return isValid
}
data class SendUiModel(
var hasBeenUpdated: Boolean = false,
val isUsdSelected: Boolean = true,
val zecValue: Long? = null,
val zatoshiValue: Long? = null,
val usdValue: BigDecimal = BigDecimal.ZERO,
val toAddress: String = "",
val memo: String = ""

View File

@ -21,17 +21,18 @@
android:orientation="vertical"
app:layout_constraintGuide_end="32dp" />
<ImageView
android:id="@+id/logo_welcome"
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottie_welcome_firstrun"
android:layout_width="168dp"
android:layout_height="168dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.497"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.17"
app:srcCompat="@drawable/ic_first_run" />
app:lottie_autoPlay="true"
app:lottie_loop="false"
app:lottie_rawRes="@raw/lottie_welcome_sync"/>
<TextView
android:id="@+id/text_header"
@ -44,7 +45,7 @@
android:textSize="@dimen/text_size_h5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/logo_welcome" />
app:layout_constraintTop_toBottomOf="@+id/lottie_welcome_firstrun" />
<TextView
android:id="@+id/text_content"

View File

@ -13,6 +13,12 @@
android:background="@color/fragment_receive_background"
tools:context=".ui.fragment.ReceiveFragment">
<include
android:id="@+id/main_app_bar"
layout="@layout/include_main_app_bar"
android:visibility="invisible"
tools:ignore="MissingConstraints" />
<!-- Shield Background -->
<ImageView
android:id="@+id/image_shield"
@ -23,7 +29,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/main_toolbar"
app:layout_constraintTop_toBottomOf="@+id/main_app_bar"
app:layout_constraintDimensionRatio="H,1:1.1676"
app:layout_constraintVertical_bias="0.062"
app:layout_constraintWidth_default="percent"

View File

@ -17,6 +17,11 @@
android:layout_height="match_parent"
android:background="@color/fragment_send_background">
<include
android:id="@+id/main_app_bar"
layout="@layout/include_main_app_bar"
android:visibility="invisible"
tools:ignore="MissingConstraints" />
<!-- -->
<!-- Guidelines -->
<!-- -->
@ -50,7 +55,7 @@
android:layout_height="48dp"
android:background="@color/zcashWhite"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/main_toolbar"
app:layout_constraintTop_toBottomOf="@+id/main_app_bar"
app:layout_constraintVertical_chainStyle="spread" />
<TextView
@ -78,6 +83,19 @@
app:layout_constraintTop_toBottomOf="@id/background_header"
app:layout_constraintVertical_chainStyle="spread_inside" />
<TextView
android:id="@+id/text_value_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:fontFamily="@font/inconsolata"
android:textColor="@color/zcashRed"
android:textSize="@dimen/text_size_caption"
app:layout_constraintStart_toStartOf="@id/transition_active_transaction_bg"
app:layout_constraintTop_toBottomOf="@id/transition_active_transaction_bg"
tools:text="invalid amount of zec" />
<Button
android:id="@+id/button_cancel_scan"
android:layout_width="wrap_content"
@ -85,6 +103,7 @@
android:layout_marginTop="4dp"
android:background="@null"
android:backgroundTint="@null"
android:elevation="0dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/cancel"
@ -118,14 +137,16 @@
app:layout_constraintTop_toTopOf="@id/transition_active_transaction_bg"
app:srcCompat="@drawable/ic_import_export_black" />
<!-- Input: Header -->
<EditText
android:id="@+id/text_value_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@null"
android:imeOptions="actionDone"
android:inputType="numberDecimal"
android:minWidth="12dp"
android:maxLength="8"
android:minWidth="12dp"
android:text="0"
android:textColor="@color/text_dark"
android:textSize="@dimen/text_size_h3"
@ -163,50 +184,51 @@
android:layout_height="0dp"
android:layout_marginTop="4dp"
android:tint="@color/text_dark"
tools:visibility="invisible"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toStartOf="@id/text_value_subheader"
app:layout_constraintTop_toTopOf="@id/text_value_subheader"
app:srcCompat="@drawable/ic_zec_symbol" />
app:srcCompat="@drawable/ic_zec_symbol"
tools:visibility="invisible" />
<TextView
android:id="@+id/text_dollar_symbol_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="$"
android:textColor="@color/text_dark"
android:textSize="18dp"
android:includeFontPadding="false"
android:textStyle="bold"
tools:visibility="invisible"
app:layout_constraintEnd_toStartOf="@id/text_value_header"
app:layout_constraintBottom_toBottomOf="@id/image_zec_symbol_header"
/>
app:layout_constraintEnd_toStartOf="@id/text_value_header"
tools:visibility="invisible" />
<TextView
android:id="@+id/text_dollar_symbol_subheader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:layout_marginRight="2dp"
android:includeFontPadding="false"
android:text="$"
android:textColor="@color/text_dark"
android:textSize="8dp"
app:layout_constraintEnd_toStartOf="@id/text_value_subheader"
app:layout_constraintTop_toTopOf="@id/image_zec_symbol_subheader" />
<!-- Address -->
<!-- Input: Address -->
<EditText
android:id="@+id/input_zcash_address"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/send_hint_input_zcash_address"
android:imeOptions="actionDone"
android:inputType="textNoSuggestions"
android:paddingRight="76dp"
android:singleLine="true"
android:paddingTop="0dp"
app:backgroundTint="@color/zcashBlack_12"
app:layout_constraintBottom_toTopOf="@id/text_area_memo"
app:layout_constraintEnd_toEndOf="@id/guideline_content_end"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@id/guideline_content_start"
app:layout_constraintTop_toBottomOf="@id/transition_active_transaction_bg" />
@ -217,17 +239,22 @@
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:fontFamily="@font/inconsolata"
android:includeFontPadding="false"
android:textColor="@color/zcashRed"
android:textSize="@dimen/text_size_caption"
app:layout_constraintStart_toStartOf="@id/input_zcash_address"
app:layout_constraintTop_toBottomOf="@id/input_zcash_address" />
app:layout_constraintTop_toBottomOf="@id/input_zcash_address"
tools:text="invalid address" />
<!-- Scan QR code -->
<ImageView
android:id="@+id/image_scan_qr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:paddingStart="6dp"
android:paddingTop="10dp"
android:paddingEnd="12dp"
android:paddingBottom="24dp"
android:tint="@color/zcashBlack_87"
app:layout_constraintBottom_toBottomOf="@id/input_zcash_address"
app:layout_constraintEnd_toEndOf="@id/input_zcash_address"
@ -238,7 +265,10 @@
android:id="@+id/image_address_shortcut"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="16dp"
android:paddingStart="20dp"
android:paddingTop="10dp"
android:paddingEnd="6dp"
android:paddingBottom="24dp"
app:layout_constraintBottom_toBottomOf="@id/image_scan_qr"
app:layout_constraintEnd_toStartOf="@id/image_scan_qr"
app:layout_constraintTop_toTopOf="@id/image_scan_qr"
@ -252,14 +282,15 @@
app:layout_constraintBottom_toTopOf="@id/input_zcash_address"
app:layout_constraintStart_toStartOf="@id/input_zcash_address" />
<!-- memo -->
<!-- Input: Memo -->
<EditText
android:id="@+id/text_area_memo"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/background_rounded_corners"
android:gravity="top|left"
android:inputType="textMultiLine"
android:imeOptions="actionDone"
android:inputType="textAutoComplete"
android:maxLength="@integer/memo_max_length"
android:padding="16dp"
app:backgroundTint="@color/zcashWhite_60"
@ -318,11 +349,11 @@
android:id="@+id/camera_placeholder"
android:layout_width="0dp"
android:layout_height="0dp"
tools:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/transition_active_transaction_bg"
app:layout_constraintEnd_toEndOf="@id/transition_active_transaction_bg"
app:layout_constraintStart_toStartOf="@id/transition_active_transaction_bg"
app:layout_constraintTop_toTopOf="@id/transition_active_transaction_bg" />
app:layout_constraintTop_toTopOf="@id/transition_active_transaction_bg"
tools:visibility="gone" />
<!-- -->
<!-- Dialog -->

View File

@ -22,17 +22,18 @@
android:orientation="vertical"
app:layout_constraintGuide_end="32dp" />
<ImageView
android:id="@+id/logo_welcome"
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottie_welcome_sync"
android:layout_width="168dp"
android:layout_height="168dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.497"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.17"
app:srcCompat="@drawable/ic_sync" />
app:lottie_autoPlay="true"
app:lottie_loop="false"
app:lottie_rawRes="@raw/lottie_welcome_firstrun"/>
<TextView
android:id="@+id/text_header"
@ -45,7 +46,7 @@
android:textSize="@dimen/text_size_h5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/logo_welcome" />
app:layout_constraintTop_toBottomOf="@+id/lottie_welcome_sync" />
<TextView
android:id="@+id/text_content"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,5 +3,4 @@
<integer name="nav_default_slow_transition_duration">400</integer>
<integer name="nav_default_quick_transition_duration">100</integer>
<integer name="memo_max_length">512</integer>
<integer name="z_address_min_length">78</integer>
</resources>

View File

@ -79,6 +79,7 @@
<string name="send_dialog_title">Send %1$s %2$s ($%3$s)?</string>
<string name="send_alert_shortcut_clicked">Paste a valid sample address for testing?</string>
<string name="send_error_address_too_short">Address is too short.</string>
<string name="send_error_address_invalid_contents">Address appears to be invalid.</string>
<string name="send_error_address_invalid_char">Address contains invalid characters.</string>
<!-- About -->

View File

@ -15,8 +15,8 @@ import javax.inject.Singleton
@Module
internal object SynchronizerModule {
// const val MOCK_LOAD_DURATION = 3_000L
const val MOCK_LOAD_DURATION = 30_000L
const val MOCK_LOAD_DURATION = 3_000L
// const val MOCK_LOAD_DURATION = 30_000L
const val MOCK_TX_INTERVAL = 20_000L
const val MOCK_ACTIVE_TX_STATE_CHANGE_INTERVAL = 5_000L

View File

@ -10,11 +10,8 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import java.math.BigDecimal
import java.math.RoundingMode
@ExtendWith(MockitoExtension::class)
internal class SendPresenterTest {
@ -74,12 +71,12 @@ internal class SendPresenterTest {
presenter.toggleCurrency()
assertTrue(!presenter.sendUiModel.isUsdSelected, "zec should be selected to avoid testing conversions")
presenter.headerValidated("1.1234535".safelyConvertToBigDecimal()!!)
assertEquals(112345350, presenter.sendUiModel.zecValue)
assertEquals("1.123454", presenter.sendUiModel.zecValue.convertZatoshiToZecString(), "5 is odd, we should round up")
assertEquals(112345350, presenter.sendUiModel.zatoshiValue)
assertEquals("1.123454", presenter.sendUiModel.zatoshiValue.convertZatoshiToZecString(), "5 is odd, we should round up")
presenter.headerValidated("1.1234565".safelyConvertToBigDecimal()!!)
assertEquals(112345650, presenter.sendUiModel.zecValue)
assertEquals("1.123456", presenter.sendUiModel.zecValue.convertZatoshiToZecString(), "6 is even, we should round down")
assertEquals(112345650, presenter.sendUiModel.zatoshiValue)
assertEquals("1.123456", presenter.sendUiModel.zatoshiValue.convertZatoshiToZecString(), "6 is even, we should round down")
}
@Test

View File

@ -22,17 +22,18 @@
android:orientation="vertical"
app:layout_constraintGuide_end="32dp" />
<ImageView
android:id="@+id/logo_welcome"
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottie_welcome_sync"
android:layout_width="168dp"
android:layout_height="168dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.497"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.17"
app:srcCompat="@drawable/ic_sync" />
app:lottie_autoPlay="true"
app:lottie_loop="false"
app:lottie_rawRes="@raw/lottie_welcome_firstrun"/>
<TextView
android:id="@+id/text_header"
@ -45,7 +46,7 @@
android:textSize="@dimen/text_size_h5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/logo_welcome" />
app:layout_constraintTop_toBottomOf="@+id/lottie_welcome_sync" />
<TextView
android:id="@+id/text_content"

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="z_address_min_length">88</integer>
</resources>