From a7c795482334c2d66339c2bae8758e4784dc40be Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Thu, 21 Feb 2019 01:37:09 -0500 Subject: [PATCH] send: send screen is done. --- .../z/android/wallet/extention/EditText.kt | 31 ++- .../wallet/ui/fragment/ReceiveFragment.kt | 5 + .../wallet/ui/fragment/ScanFragment.kt | 33 ++- .../wallet/ui/fragment/SendFragment.kt | 208 +++++++------- .../wallet/ui/presenter/SendPresenter.kt | 255 +++++++++++++++--- .../src/main/res/layout/fragment_firstrun.xml | 11 +- .../src/main/res/layout/fragment_receive.xml | 8 +- .../app/src/main/res/layout/fragment_send.xml | 67 +++-- .../app/src/main/res/layout/fragment_sync.xml | 11 +- .../main/res/raw/lottie_welcome_firstrun.json | 1 + .../src/main/res/raw/lottie_welcome_sync.json | 1 + .../app/src/main/res/values/integers.xml | 1 - .../app/src/main/res/values/strings.xml | 1 + .../wallet/di/module/SynchronizerModule.kt | 4 +- .../wallet/ui/presenter/SendPresenterTest.kt | 11 +- .../src/ztestnet/res/layout/fragment_sync.xml | 11 +- .../app/src/ztestnet/res/values/integers.xml | 4 - 17 files changed, 447 insertions(+), 216 deletions(-) create mode 100644 zcash-android-wallet-app/app/src/main/res/raw/lottie_welcome_firstrun.json create mode 100644 zcash-android-wallet-app/app/src/main/res/raw/lottie_welcome_sync.json delete mode 100644 zcash-android-wallet-app/app/src/ztestnet/res/values/integers.xml diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/EditText.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/EditText.kt index d1b12bb..4750c35 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/EditText.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/EditText.kt @@ -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() + ?.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) } \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ReceiveFragment.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ReceiveFragment.kt index 914c479..b8d7b2e 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ReceiveFragment.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ReceiveFragment.kt @@ -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() diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ScanFragment.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ScanFragment.kt index b058f8d..0f5deb1 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ScanFragment.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ScanFragment.kt @@ -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}") + } } } diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/SendFragment.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/SendFragment.kt index d4ebd7e..1a9dd8a 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/SendFragment.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/SendFragment.kt @@ -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() ?.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 diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/SendPresenter.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/SendPresenter.kt index 067dc6f..9a606c3 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/SendPresenter.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/SendPresenter.kt @@ -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) = 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 = "" diff --git a/zcash-android-wallet-app/app/src/main/res/layout/fragment_firstrun.xml b/zcash-android-wallet-app/app/src/main/res/layout/fragment_firstrun.xml index fab808a..97f5c35 100644 --- a/zcash-android-wallet-app/app/src/main/res/layout/fragment_firstrun.xml +++ b/zcash-android-wallet-app/app/src/main/res/layout/fragment_firstrun.xml @@ -21,17 +21,18 @@ android:orientation="vertical" app:layout_constraintGuide_end="32dp" /> - + app:lottie_autoPlay="true" + app:lottie_loop="false" + app:lottie_rawRes="@raw/lottie_welcome_sync"/> + app:layout_constraintTop_toBottomOf="@+id/lottie_welcome_firstrun" /> + + + @@ -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" /> + +