From f7d46637706ecbd879b5c010829ebe8e2c538333 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Thu, 26 Mar 2020 09:50:42 -0400 Subject: [PATCH] Add transparent wallet support. --- .../z/ecc/android/ui/send/SendFragment.kt | 371 ++++++++++++++ .../color/selector_button_border_primary.xml | 6 + .../selector_button_border_secondary.xml | 6 + .../color/selector_button_text_primary.xml | 6 + .../color/selector_button_text_secondary.xml | 6 + .../color/selector_selectable_text_light.xml | 6 + .../background_button_square_primary.xml | 5 + .../background_button_square_secondary.xml | 5 + .../drawable/ic_add_circle_outline_24dp.xml | 5 + app/src/main/res/drawable/ic_send_24dp.xml | 5 + app/src/main/res/layout/fragment_send.xml | 480 ++++++++++++++++++ app/src/main/res/values/colors.xml | 4 + 12 files changed, 905 insertions(+) create mode 100644 app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt create mode 100644 app/src/main/res/color/selector_button_border_primary.xml create mode 100644 app/src/main/res/color/selector_button_border_secondary.xml create mode 100644 app/src/main/res/color/selector_button_text_primary.xml create mode 100644 app/src/main/res/color/selector_button_text_secondary.xml create mode 100644 app/src/main/res/color/selector_selectable_text_light.xml create mode 100644 app/src/main/res/drawable/background_button_square_primary.xml create mode 100644 app/src/main/res/drawable/background_button_square_secondary.xml create mode 100644 app/src/main/res/drawable/ic_add_circle_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_send_24dp.xml create mode 100644 app/src/main/res/layout/fragment_send.xml diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt new file mode 100644 index 0000000..43ee940 --- /dev/null +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt @@ -0,0 +1,371 @@ +package cash.z.ecc.android.ui.send + +import android.content.ClipboardManager +import android.content.Context +import android.content.res.ColorStateList +import android.os.Bundle +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.core.view.forEach +import androidx.core.widget.doAfterTextChanged +import cash.z.ecc.android.R +import cash.z.ecc.android.databinding.FragmentSendBinding +import cash.z.ecc.android.di.viewmodel.activityViewModel +import cash.z.ecc.android.ext.* +import cash.z.ecc.android.feedback.Report +import cash.z.ecc.android.feedback.Report.Funnel.Send +import cash.z.ecc.android.feedback.Report.Tap.* +import cash.z.ecc.android.ui.base.BaseFragment +import cash.z.wallet.sdk.Synchronizer +import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance +import cash.z.wallet.sdk.ext.* +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class SendFragment : BaseFragment(), + ClipboardManager.OnPrimaryClipChangedListener { + override val screen = Report.Screen.SEND + + private var maxZatoshi: Long? = null + private var useShieldedFunds: Boolean = true + set(value) { + // if we're switching to shielded then no need to prevent it + // otherwise, do not switch to transparent unless the user agrees to reset the memo + if (value || resetMemo()) { + field = value + sendViewModel.useShieldedFunds = value + applyMemo() + } + } + + val sendViewModel: SendViewModel by activityViewModel() + + val pasteLimit = 20 + + override fun inflate(inflater: LayoutInflater): FragmentSendBinding = + FragmentSendBinding.inflate(inflater) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) +// getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN); + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_to_nav_home) { tapped(SEND_BACK) } + binding.sendButtonHitArea.setOnClickListener { + onSubmit().also { tapped(SEND_NEXT) } + } +// binding.textBannerAction.setOnClickListener { +// onPaste().also { tapped(SEND_PASTE) } +// } +// binding.textBannerMessage.setOnClickListener { +// onPaste().also { tapped(SEND_PASTE) } +// } + binding.textPaste.setOnClickListener { + onPaste().also { tapped(SEND_PASTE) } + } + binding.textMax.setOnClickListener { + onMax().also { tapped(SEND_MAX) } + } + + binding.inputZcashAddress.onEditorActionDone(::onSubmit).also { tapped(SEND_DONE_ADDRESS) } + binding.inputZcashAmount.onEditorActionDone(::onSubmit).also { tapped(SEND_DONE_AMOUNT) } + + binding.inputZcashAddress.apply { + doAfterTextChanged { + val trim = text.toString().trim() + if (text.toString() != trim) { + binding.inputZcashAddress + .findViewById(R.id.input_zcash_address).setText(trim) + } + onAddressChanged(trim) + } + } + + binding.inputMemo.doAfterTextChanged { + sendViewModel.memo = binding.inputMemo.text.toString() + updateMemoCount() + } + + binding.textLayoutAddress.setEndIconOnClickListener { + mainActivity?.maybeOpenScan().also { tapped(SEND_SCAN) } + } + + // new behaviors + binding.boxShieldedFunds.isClickable = true + binding.boxTransparentFunds.isClickable = true + useShieldedFunds = true + binding.boxShieldedFunds.setOnClickListener { + if (!it.isSelected) { + useShieldedFunds = true + } + } + binding.boxTransparentFunds.setOnClickListener { + if (!it.isSelected) { + useShieldedFunds = false + } + } + binding.textSubheader.text.toColoredSpan(R.color.colorPrimaryVariant, "shielded").let { spannable -> + val start = binding.textSubheader.text.indexOf("transparent") + spannable.setSpan(ForegroundColorSpan(R.color.colorSecondaryVariant.toAppColor()), start, start + "transparent".length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + binding.textSubheader.text = spannable + } + + // memo + sendViewModel.afterInitFromAddress { + binding.textIncludedAddress.text = "sent from ${sendViewModel.fromAddress}" + } + + binding.buttonAddMemo.setOnClickListener { + onAddMemo((maxZatoshi ?: 0L) > 0L) + } + + binding.clearMemo.setOnClickListener { + onClearMemo().also { tapped(SEND_MEMO_CLEAR) } + } + + binding.checkIncludeAddress.setOnCheckedChangeListener { _, _-> + onIncludeAddressInMemo(binding.checkIncludeAddress.isChecked) + } + } + + private fun selectGroup(group: ViewGroup, isSelected: Boolean) { + group.isSelected = isSelected + group.forEach { + it.isSelected = isSelected + } + } + + private fun onAddressChanged(address: String) { + if (address.length <= pasteLimit) { + updateClipboardBanner() + } else { + sendViewModel.toAddress = binding.inputZcashAddress.text.toString() + resumedScope.launch { + var type = when (sendViewModel.validateAddress(address)) { + is Synchronizer.AddressType.Transparent -> "This is a valid transparent address" to R.color.zcashGreen + is Synchronizer.AddressType.Shielded -> "This is a valid shielded address" to R.color.zcashGreen + is Synchronizer.AddressType.Invalid -> "This address appears to be invalid" to R.color.zcashRed + } + if (address == sendViewModel.synchronizer.getAddress()) type = + "Warning, this appears to be your address!" to R.color.zcashRed + binding.textLayoutAddress.helperText = type.first + binding.textLayoutAddress.setHelperTextColor(ColorStateList.valueOf(type.second.toAppColor())) + } + } + } + + + private fun onSubmit(unused: EditText? = null) { + // TODO: tech debt: improve this logic to be driven by the model rather than this clumsy storage of data in the UI + sendViewModel.toAddress = binding.inputZcashAddress.text.toString() + binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it } + sendViewModel.memo = if (useShieldedFunds) binding.inputMemo.text.toString() else "" + sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) { + if (it == null) { + sendViewModel.funnel(Send.SendPageComplete) + mainActivity?.safeNavigate(R.id.action_nav_send_to_send_confirm) + } else { + resumedScope.launch { + binding.textAddressError.text = it + delay(3000L) + binding.textAddressError.text = "" + } + } + } + } + + private fun onMax() { + if (maxZatoshi != null) { + binding.inputZcashAmount.apply { + setText(maxZatoshi.convertZatoshiToZecString(8)) + postDelayed({ + requestFocus() + setSelection(text?.length ?: 0) + }, 10L) + } + } + } + + + override fun onAttach(context: Context) { + super.onAttach(context) + mainActivity?.clipboard?.addPrimaryClipChangedListener(this) + } + + override fun onDetach() { + super.onDetach() + mainActivity?.clipboard?.removePrimaryClipChangedListener(this) + } + + override fun onResume() { + super.onResume() + applyModel() + updateClipboardBanner() + sendViewModel.synchronizer.balances.collectWith(resumedScope) { + onBalanceUpdated(it) + } + } + + private fun applyModel() { + useShieldedFunds = sendViewModel.useShieldedFunds + if (sendViewModel.zatoshiAmount > 0L) { + sendViewModel.zatoshiAmount.convertZatoshiToZecString(8).let { amount -> + binding.inputZcashAmount.setText(amount) + } + } else { + binding.inputZcashAmount.setText(null) + } + binding.inputZcashAddress.setText(sendViewModel.toAddress) + applyMemo() + } + + fun applyMemo() { + if (sendViewModel.isMemoAdded) { + binding.groupMemo.visibility = View.VISIBLE + binding.buttonAddMemo.visibility = View.GONE + } else { + binding.groupMemo.visibility = View.GONE + binding.buttonAddMemo.visibility = View.VISIBLE + } + selectGroup(binding.boxShieldedFunds, sendViewModel.useShieldedFunds) + selectGroup(binding.boxTransparentFunds, !sendViewModel.useShieldedFunds) + + binding.inputMemo.setText(sendViewModel.memo) + binding.checkIncludeAddress.isChecked = sendViewModel.includeFromAddress + binding.textIncludedAddress.goneIf(!sendViewModel.useShieldedFunds || !sendViewModel.includeFromAddress || !sendViewModel.isMemoAdded) + } + + private fun onBalanceUpdated(balance: WalletBalance) { + val zecString= balance.availableZatoshi.coerceAtLeast(0L).convertZatoshiToZecString(8) + binding.textLayoutAmount.helperText = + "You have $zecString available" + binding.textShieldedFundsAmount.text = "\$${zecString}" + maxZatoshi = (balance.availableZatoshi - ZcashSdk.MINERS_FEE_ZATOSHI).coerceAtLeast(0L) + } + + override fun onPrimaryClipChanged() { + twig("clipboard changed!") + updateClipboardBanner() + } + + private fun updateClipboardBanner() { + val invalidAddressOnClipboard = loadAddressFromClipboard() == null + val hidePaste = invalidAddressOnClipboard || (binding.inputZcashAddress.text ?: "").length >= pasteLimit - 1 + binding.textPaste.goneIf(hidePaste) + binding.textLayoutAddress.helperText = + "${if (hidePaste) "E" else "Paste or e"}nter a valid Zcash address" + binding.textLayoutAddress.setHelperTextColor(ColorStateList.valueOf(R.color.text_light_dimmed.toAppColor())) + } + + private fun onAddMemo(hasShieldedFunds: Boolean) { + if (!useShieldedFunds) { + //TODO: track dialog reference + val builder = MaterialAlertDialogBuilder(mainActivity) + .setTitle("Shielded Transaction Required") + .setMessage("Memos are not supported for transparent transactions. To add a memo, you must send shielded funds.") + .setCancelable(true) + .setPositiveButton("Ok") { dialog, _ -> + dialog.dismiss() + } + if (hasShieldedFunds) { + builder.setNegativeButton("Switch to Shielded") { dialog, _ -> + dialog.dismiss() + useShieldedFunds = true + onAddMemo(hasShieldedFunds) + } + } + builder.show() + } else { + sendViewModel.isMemoAdded = true + applyMemo() + binding.inputMemo.requestFocus() + } + } + + private fun onClearMemo() { + sendViewModel.isMemoAdded = false + binding.inputMemo.setText("") + sendViewModel.memo = "" + binding.groupMemo.visibility = View.GONE + binding.buttonAddMemo.visibility = View.VISIBLE + binding.textIncludedAddress.visibility = View.GONE + } + + private fun resetMemo(): Boolean { + if (sendViewModel.memo.isNullOrEmpty()) { + onClearMemo() + return true + } else { + MaterialAlertDialogBuilder(mainActivity) + .setTitle("Are you sure?") + .setMessage("Memos are not supported for transparent transactions. If you switch to transparent funds, your memo will be lost.") + .setCancelable(true) + .setPositiveButton("Clear Memo") { dialog, _ -> + dialog.dismiss() + onClearMemo() + useShieldedFunds = false + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + } + .show() + return false + } + } + + private fun onIncludeAddressInMemo(checked: Boolean) { + binding.textIncludedAddress.goneIf(!checked) + sendViewModel.includeFromAddress = checked + if (checked) { + tapped(SEND_MEMO_INCLUDE) +// getString(R.string.send_memo_included_message) + } else { + tapped(SEND_MEMO_EXCLUDE) +// getString(R.string.send_memo_excluded_message) + } + updateMemoCount() + } + + private fun updateMemoCount() { + var count = sendViewModel.createMemoToSend().length + binding.textMemoCount.text = "$count/512" + val color = if (count > 512) R.color.zcashRed else R.color.text_light_dimmed + binding.textMemoCount.setTextColor(color.toAppColor()) + } + + private fun onPaste() { + mainActivity?.clipboard?.let { clipboard -> + if (clipboard.hasPrimaryClip()) { + binding.inputZcashAddress.setText(clipboard.text()) + } + } + binding.textPaste.visibility = View.GONE + } + + private fun loadAddressFromClipboard(): String? { + mainActivity?.clipboard?.apply { + if (hasPrimaryClip()) { + text()?.let { text -> + if (text.startsWith("zs") && text.length > 70) { + return@loadAddressFromClipboard text.toString() + } + // treat t-addrs differently in the future + if (text.startsWith("t1") && text.length > 32) { + return@loadAddressFromClipboard text.toString() + } + } + } + } + return null + } + + private fun ClipboardManager.text(): CharSequence = + primaryClip!!.getItemAt(0).coerceToText(mainActivity) +} \ No newline at end of file diff --git a/app/src/main/res/color/selector_button_border_primary.xml b/app/src/main/res/color/selector_button_border_primary.xml new file mode 100644 index 0000000..9419873 --- /dev/null +++ b/app/src/main/res/color/selector_button_border_primary.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/selector_button_border_secondary.xml b/app/src/main/res/color/selector_button_border_secondary.xml new file mode 100644 index 0000000..e60b455 --- /dev/null +++ b/app/src/main/res/color/selector_button_border_secondary.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/selector_button_text_primary.xml b/app/src/main/res/color/selector_button_text_primary.xml new file mode 100644 index 0000000..e8d83f0 --- /dev/null +++ b/app/src/main/res/color/selector_button_text_primary.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/selector_button_text_secondary.xml b/app/src/main/res/color/selector_button_text_secondary.xml new file mode 100644 index 0000000..c5adb54 --- /dev/null +++ b/app/src/main/res/color/selector_button_text_secondary.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/selector_selectable_text_light.xml b/app/src/main/res/color/selector_selectable_text_light.xml new file mode 100644 index 0000000..9e9dc2f --- /dev/null +++ b/app/src/main/res/color/selector_selectable_text_light.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_square_primary.xml b/app/src/main/res/drawable/background_button_square_primary.xml new file mode 100644 index 0000000..c4b2f95 --- /dev/null +++ b/app/src/main/res/drawable/background_button_square_primary.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_button_square_secondary.xml b/app/src/main/res/drawable/background_button_square_secondary.xml new file mode 100644 index 0000000..123146a --- /dev/null +++ b/app/src/main/res/drawable/background_button_square_secondary.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_circle_outline_24dp.xml b/app/src/main/res/drawable/ic_add_circle_outline_24dp.xml new file mode 100644 index 0000000..66d3247 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle_outline_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_send_24dp.xml b/app/src/main/res/drawable/ic_send_24dp.xml new file mode 100644 index 0000000..33efc5c --- /dev/null +++ b/app/src/main/res/drawable/ic_send_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_send.xml b/app/src/main/res/layout/fragment_send.xml new file mode 100644 index 0000000..76cf9f7 --- /dev/null +++ b/app/src/main/res/layout/fragment_send.xml @@ -0,0 +1,480 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7a0129d..230cf1b 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -8,8 +8,11 @@ @color/zcashYellow + #80FFB900 #805E08 #4D3805 + #4A90E2 + #804A90E2 #A1A1A1 @color/text_light @color/background_banner @@ -59,6 +62,7 @@ @color/zcashBlack_dark #282828 + @color/zcashBlack_54 @color/zcashBlack_87 #1FBB666A @color/text_light