zcash-android-wallet/app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt

371 lines
14 KiB
Kotlin

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<FragmentSendBinding>(),
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<EditText>(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)
}