diff --git a/zcash-android-wallet-app/app/build.gradle b/zcash-android-wallet-app/app/build.gradle index 3a2d97e..d47fd4e 100644 --- a/zcash-android-wallet-app/app/build.gradle +++ b/zcash-android-wallet-app/app/build.gradle @@ -106,7 +106,13 @@ dependencies { debugImplementation deps.stetho mockImplementation deps.stetho - testImplementation deps.junit + testImplementation 'org.mockito:mockito-junit-jupiter:2.24.0' + testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0' + testImplementation "org.junit.jupiter:junit-jupiter-api:5.4.0" + testImplementation "org.junit.jupiter:junit-jupiter-engine:5.4.0" + testImplementation "org.junit.jupiter:junit-jupiter-migrationsupport:5.4.0" + androidTestImplementation deps.androidx.test.runner androidTestImplementation deps.androidx.test.espresso + } diff --git a/zcash-android-wallet-app/app/libs/zcash-android-wallet-sdk-1.6.0.aar b/zcash-android-wallet-app/app/libs/zcash-android-wallet-sdk-1.6.0.aar index c36c550..85887b2 100644 Binary files a/zcash-android-wallet-app/app/libs/zcash-android-wallet-sdk-1.6.0.aar and b/zcash-android-wallet-app/app/libs/zcash-android-wallet-sdk-1.6.0.aar differ diff --git a/zcash-android-wallet-app/app/src/main/AndroidManifest.xml b/zcash-android-wallet-app/app/src/main/AndroidManifest.xml index ce71389..90591e0 100644 --- a/zcash-android-wallet-app/app/src/main/AndroidManifest.xml +++ b/zcash-android-wallet-app/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleConfig.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleConfig.kt index 2beefd7..ca7e6e7 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleConfig.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleConfig.kt @@ -1,6 +1,8 @@ package cash.z.android.wallet.sample import cash.z.wallet.sdk.data.SampleSeedProvider +import java.math.BigDecimal +import java.math.MathContext object AliceWallet { const val name = "test.reference.alice" @@ -45,11 +47,5 @@ object SampleProperties { const val COMPACT_BLOCK_PORT = 9067 val wallet = AliceWallet // TODO: placeholder until we have a network service for this - const val USD_PER_ZEC = 49.07 - - /** - * A simple flag that helps with removing shortcuts in the code used during development. - * TODO: either elevate this to a real thing (based off a system property or some such) or delete it! - */ - const val DEV_MODE = false + val USD_PER_ZEC = BigDecimal("49.07", MathContext.DECIMAL128) } \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/activity/MainActivity.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/activity/MainActivity.kt index f25c827..32873d9 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/activity/MainActivity.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/activity/MainActivity.kt @@ -20,7 +20,6 @@ import androidx.navigation.ui.setupWithNavController import cash.z.android.wallet.BuildConfig import cash.z.android.wallet.R import cash.z.android.wallet.ZcashWalletApplication -import cash.z.android.wallet.sample.SampleProperties.DEV_MODE import dagger.Module import dagger.android.ContributesAndroidInjector import dagger.android.support.DaggerAppCompatActivity @@ -49,12 +48,12 @@ class MainActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - if(!DEV_MODE)synchronizer.start(this) + synchronizer.start(this) } override fun onDestroy() { super.onDestroy() - if(!DEV_MODE)synchronizer.stop() + synchronizer.stop() } override fun onBackPressed() { diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/adapter/TransactionAdapter.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/adapter/TransactionAdapter.kt index c45c9f5..1df114d 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/adapter/TransactionAdapter.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/adapter/TransactionAdapter.kt @@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView import cash.z.android.wallet.R import cash.z.android.wallet.extention.toAppColor import cash.z.wallet.sdk.dao.WalletTransaction +import cash.z.wallet.sdk.ext.convertZatoshiToZec import cash.z.wallet.sdk.ext.toZec import java.text.SimpleDateFormat import java.util.* @@ -40,7 +41,7 @@ class TransactionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) val sign = if(tx.isSend) "-" else "+" val amountColor = if (tx.isSend) R.color.text_dark_dimmed else R.color.colorPrimary val transactionColor = if(tx.isSend) R.color.send_associated else R.color.receive_associated - val zecAbsoluteValue = tx.value.absoluteValue.toZec(3) + val zecAbsoluteValue = tx.value.absoluteValue.convertZatoshiToZec(3) status.setBackgroundColor(transactionColor.toAppColor()) timestamp.text = if (!tx.isMined || tx.timeInSeconds == 0L) "Pending" else formatter.format(tx.timeInSeconds * 1000) Log.e("TWIG-z", "TimeInSeconds: ${tx.timeInSeconds}") diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/HomeFragment.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/HomeFragment.kt index 9e6b7ff..12ac3cc 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/HomeFragment.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/HomeFragment.kt @@ -23,7 +23,6 @@ import cash.z.android.wallet.R import cash.z.android.wallet.databinding.FragmentHomeBinding import cash.z.android.wallet.extention.* import cash.z.android.wallet.sample.SampleProperties -import cash.z.android.wallet.sample.SampleProperties.DEV_MODE import cash.z.android.wallet.ui.adapter.TransactionAdapter import cash.z.android.wallet.ui.presenter.HomePresenter import cash.z.android.wallet.ui.util.AlternatingRowColorDecoration @@ -33,7 +32,7 @@ import cash.z.wallet.sdk.dao.WalletTransaction import cash.z.wallet.sdk.data.ActiveSendTransaction import cash.z.wallet.sdk.data.ActiveTransaction import cash.z.wallet.sdk.data.TransactionState -import cash.z.wallet.sdk.ext.toZec +import cash.z.wallet.sdk.ext.* import com.google.android.material.snackbar.Snackbar import com.leinardi.android.speeddial.SpeedDialActionItem import dagger.Module @@ -168,11 +167,11 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP //TODO: pull some of this logic into the presenter, particularly the part that deals with ZEC <-> USD price conversion override fun updateBalance(old: Long, new: Long) { - val zecValue = new/1e8 - setZecValue(zecValue) - setUsdValue(SampleProperties.USD_PER_ZEC * zecValue) + val zecValue = new.convertZatoshiToZec() + setZecValue(zecValue.toZecString(3)) + setUsdValue(zecValue.convertZecToUsd(SampleProperties.USD_PER_ZEC).toUsdString()) - onContentRefreshComplete(zecValue) + onContentRefreshComplete(new) } override fun setTransactions(transactions: List) { @@ -245,12 +244,12 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP private fun updatePrimaryTransaction(transaction: ActiveTransaction, transactionState: TransactionState) { setActiveTransactionsShown(true) Log.e("TWIG", "setting transaction state to ${transactionState::class.simpleName}") - var title = "Active Transaction" - var subtitle = "Processing..." + var title = binding.includeContent.textActiveTransactionTitle.text?.toString() ?: "" + var subtitle = binding.includeContent.textActiveTransactionSubtitle.text?.toString() ?: "" when (transactionState) { TransactionState.Creating -> { binding.includeContent.headerActiveTransaction.visibility = View.VISIBLE - title = "Preparing ${transaction.value.toZec(3)} ZEC" + title = "Preparing ${transaction.value.convertZatoshiToZecString(3)} ZEC" subtitle = "to ${(transaction as ActiveSendTransaction).toAddress}" setTransactionActive(transaction, true) } @@ -281,7 +280,7 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP binding.includeContent.lottieActiveTransaction.playAnimation() title = "ZEC Sent" subtitle = "Today at 2:11pm" - binding.includeContent.textActiveTransactionValue.text = transaction.value.toZec(3).toString() + binding.includeContent.textActiveTransactionValue.text = transaction.value.convertZatoshiToZecString(3) binding.includeContent.textActiveTransactionValue.visibility = View.VISIBLE binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE } else { @@ -405,8 +404,7 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP .create() } - private fun setUsdValue(value: Double) { - val valueString = String.format("$ %,.2f",value) + private fun setUsdValue(valueString: String) { val hairSpace = "\u200A" // val adjustedValue = "$$hairSpace$valueString" val textSpan = SpannableString(valueString) @@ -415,8 +413,8 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP binding.includeHeader.textBalanceUsd.text = textSpan } - private fun setZecValue(value: Double) { - binding.includeHeader.textBalanceZec.text = if(value == 0.0) "0" else String.format("%.3f",value) + private fun setZecValue(value: String) { + binding.includeHeader.textBalanceZec.text = value // // bugfix: there is a bug in motionlayout that causes text to flicker as it is resized because the last character doesn't fit. Padding both sides with a thin space works around this bug. @@ -431,8 +429,8 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP * If the balance changes from zero, the wallet is no longer empty so hide the empty view. * But don't do either of these things if the situation has not changed. */ - private fun onContentRefreshComplete(value: Double) { - val isEmpty = value <= 0.0 + private fun onContentRefreshComplete(value: Long) { + val isEmpty = value <= 0L // wasEmpty isn't enough info. it must be considered along with whether these views were ever initialized val wasEmpty = binding.includeContent.groupEmptyViewItems.visibility == View.VISIBLE // situation has changed when we weren't initialized but now we have a balance or emptiness has changed @@ -537,42 +535,15 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP lateinit var headerEmptyViews: Array lateinit var headerFullViews: Array - fun shrink(): Double { - return binding.includeHeader.textBalanceZec.text.toString().trim().toDouble() - Random.nextDouble(5.0) - } - fun grow(): Double { - return binding.includeHeader.textBalanceZec.text.toString().trim().toDouble() + Random.nextDouble(5.0) - } - fun reduceValue() { - shrink().let { - if(it < 0) { setZecValue(0.0); toggleViews(empty); forceRedraw() } - else view?.postDelayed({ - setZecValue(it) - setUsdValue(it*75.0) - reduceValue() - }, delay) - } - } - fun increaseValue(target: Double) { - grow().let { - if(it > target) { setZecValue(target); setUsdValue(target*75.0); toggleViews(empty) } - else view?.postDelayed({ - setZecValue(it) - setUsdValue(it*75.0) - increaseValue(target) - if (headerFullViews[0].parent == null || headerEmptyViews[0].parent != null) toggleViews(false) - forceRedraw() - }, delay) - } - } + fun forceRedraw() { view?.postDelayed({ binding.includeHeader.containerHomeHeader.progress = binding.includeHeader.containerHomeHeader.progress - 0.1f }, delay * 2) } - internal fun toggle(isEmpty: Boolean) { - toggleValues(isEmpty) - } +// internal fun toggle(isEmpty: Boolean) { +// toggleValues(isEmpty) +// } internal fun toggleViews(isEmpty: Boolean) { Log.e("TWIG-t", "toggling views to isEmpty == $isEmpty") @@ -643,14 +614,14 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP view?.postDelayed(::forceRedraw, delay * 2) } - internal fun toggleValues(isEmpty: Boolean) { - empty = isEmpty - if(empty) { - reduceValue() - } else { - increaseValue(Random.nextDouble(20.0, 100.0)) - } - } +// internal fun toggleValues(isEmpty: Boolean) { +// empty = isEmpty +// if(empty) { +// reduceValue() +// } else { +// increaseValue(Random.nextDouble(20.0, 100.0)) +// } +// } inner class HomeTransitionListener : Transition.TransitionListener { 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 736c3df..d4b38d8 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 @@ -1,27 +1,67 @@ package cash.z.android.wallet.ui.fragment +import android.animation.Animator import android.content.Context import android.content.pm.PackageManager +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.media.Image import android.os.Bundle import android.view.LayoutInflater import android.view.View +import android.view.ViewAnimationUtils import android.view.ViewGroup import androidx.core.content.ContextCompat import androidx.databinding.DataBindingUtil +import cash.z.android.cameraview.CameraView import cash.z.android.wallet.R import cash.z.android.wallet.databinding.FragmentScanBinding +import cash.z.android.wallet.extention.Toaster import cash.z.android.wallet.ui.activity.MainActivity +import com.google.firebase.ml.vision.FirebaseVision +import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcode +import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcodeDetectorOptions +import com.google.firebase.ml.vision.common.FirebaseVisionImage import dagger.Module import dagger.android.ContributesAndroidInjector + /** * Fragment for scanning addresss, hopefully. */ class ScanFragment : BaseFragment() { lateinit var binding: FragmentScanBinding + var barcodeCallback: BarcodeCallback? = null -// private var cameraSource: CameraSource? = null + interface BarcodeCallback { + fun onBarcodeScanned(value: String) + } + + private val revealCamera = Runnable { + binding.overlayBarcodeScan.apply { + val cX = measuredWidth / 2 + val cY = measuredHeight / 2 + ViewAnimationUtils.createCircularReveal(this, cX, cY, 0.0f, cX.toFloat()).start() + postDelayed({ + val v:View = this + v.animate().alpha(0.0f).apply { duration = 2400L }.setListener(object : Animator.AnimatorListener { + override fun onAnimationRepeat(animation: Animator?) { + } + + override fun onAnimationStart(animation: Animator?) { + } + + override fun onAnimationEnd(animation: Animator?) { + binding.overlayBarcodeScan.visibility = View.GONE + } + override fun onAnimationCancel(animation: Animator?) { + binding.overlayBarcodeScan.visibility = View.GONE + } + }) + },500L) + } + } private val requiredPermissions: Array get() { @@ -77,6 +117,8 @@ class ScanFragment : BaseFragment() { override fun onResume() { super.onResume() + binding.overlayBarcodeScan.post(revealCamera) + System.err.println("camoorah : onResume ScanFragment") if(allPermissionsGranted()) onStartCamera() // launch { // sendPresenter.start() @@ -161,9 +203,62 @@ class ScanFragment : BaseFragment() { private fun onStartCamera() { with(binding.cameraView) { + // workaround race conditions with google play services downloading the binaries for Firebase Vision APIs postDelayed({ + firebaseCallback = PoCallback() start() - }, 1500L) + }, 1000L) + } + } + + inner class PoCallback : CameraView.FirebaseCallback { + val options = FirebaseVisionBarcodeDetectorOptions.Builder() + .setBarcodeFormats(FirebaseVisionBarcode.FORMAT_QR_CODE) + .build() + val barcodeDetector = FirebaseVision.getInstance().getVisionBarcodeDetector(options) + var cameraId = getBackCameraId() + + private fun getBackCameraId(): String { + val manager = mainActivity.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + for (cameraId in manager.cameraIdList) { + val characteristics = manager.getCameraCharacteristics(cameraId) + val cOrientation = characteristics.get(CameraCharacteristics.LENS_FACING)!! + if (cOrientation == CameraCharacteristics.LENS_FACING_BACK) return cameraId + } + throw IllegalArgumentException("no rear-facing camera found!") + } + + 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 + val message = "found: $value" + Toaster.short(message) + onScanSuccess(value!!) + // TODO: highlight the barcode + var bounds = barcode.boundingBox + var corners = barcode.cornerPoints + binding.cameraView.setBarcode(barcode) + } + } + } + } + + private var pendingSuccess = false + private fun onScanSuccess(value: String) { + if (!pendingSuccess) { + pendingSuccess = true + with(binding.cameraView) { + postDelayed({ + barcodeCallback?.onBarcodeScanned(value) + }, 3000L) + } } } @@ -177,7 +272,6 @@ class ScanFragment : BaseFragment() { } } - @Module abstract class ScanFragmentModule { @ContributesAndroidInjector 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 b9585b0..9bbce1e 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 @@ -17,56 +17,43 @@ import androidx.core.content.getSystemService import androidx.core.graphics.drawable.DrawableCompat import androidx.core.text.toSpannable import androidx.databinding.DataBindingUtil -import androidx.navigation.fragment.FragmentNavigatorExtras -import cash.z.android.qrecycler.QScanner +import androidx.fragment.app.Fragment import cash.z.android.wallet.BuildConfig 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.sample.SampleProperties.DEV_MODE 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 javax.inject.Inject import kotlin.math.absoluteValue - /** * Fragment for sending Zcash. * */ -class SendFragment : BaseFragment(), SendPresenter.SendView { +class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.BarcodeCallback { - @Inject - lateinit var qrCodeScanner: QScanner lateinit var sendPresenter: SendPresenter lateinit var binding: FragmentSendBinding - private val zecFormatter = DecimalFormat("#.######") - private val usdFormatter = DecimalFormat("###,###,##0.00") - private val usdSelected get() = binding.groupUsdSelected.visibility == View.VISIBLE - private val zec = R.string.zec_abbreviation.toAppString() private val usd = R.string.usd_abbreviation.toAppString() + + // + // Lifecycle + // + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { -// val enterTransitionSet = TransitionInflater.from(mainActivity).inflateTransition(R.transition.transition_zec_sent).apply { -// duration = 3500L -// } -// -// this.sharedElementReturnTransition = enterTransitionSet -// this.sharedElementEnterTransition = enterTransitionSet -// -// this.allowReturnTransitionOverlap = false -// allowEnterTransitionOverlap = false - return DataBindingUtil.inflate( inflater, R.layout.fragment_send, container, false ).let { @@ -75,6 +62,11 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { } } + override fun onAttachFragment(childFragment: Fragment?) { + super.onAttachFragment(childFragment) + (childFragment as? ScanFragment)?.barcodeCallback = this + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) (activity as MainActivity).let { mainActivity -> @@ -83,119 +75,6 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { mainActivity.supportActionBar?.setTitle(R.string.destination_title_send) } init() - initDialog() - } - - // temporary function until presenter is setup - private fun init() { - binding.imageSwapCurrency.setOnClickListener { - onToggleCurrency() - } - - binding.textValueHeader.apply { - afterTextChanged { - tryIgnore { - // only update things if the user is actively editing. in other words, don't update on programmatic changes - if (binding.textValueHeader.hasFocus()) { - val value = binding.textValueHeader.text.toString().toDouble() - binding.textValueSubheader.text = if (usdSelected) { - zecFormatter.format(value / SampleProperties.USD_PER_ZEC) + " $zec" - } else { - if (value == 0.0) "0 $usd" - else usdFormatter.format(value * SampleProperties.USD_PER_ZEC) + " $usd" - } - } - } - } - } - - binding.textAreaMemo.afterTextChanged { - binding.textMemoCharCount.text = - "${binding.textAreaMemo.text.length} / ${resources.getInteger(R.integer.memo_max_length)}" - } - - binding.buttonSendZec.setOnClickListener { - showSendDialog() - } - binding.buttonSendZec.isEnabled = false - - with(binding.imageScanQr) { - TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_scan_qr)) - } - binding.imageAddressShortcut?.apply { - if (BuildConfig.DEBUG) { - TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_address_shortcut)) - setOnClickListener(::onPasteShortcutAddress) - } else { - visibility = View.GONE - } - } - binding.imageScanQr.setOnClickListener(::onScanQrCode) - binding.textValueHeader.setText("0") - binding.textValueSubheader.text = - mainActivity.resources.getString(R.string.send_subheader_value, if (usdSelected) zec else usd) - - // allow background taps to dismiss the keyboard and clear focus - binding.contentFragmentSend.setOnClickListener { - it?.findFocus()?.clearFocus() - formatUserInput() - hideKeyboard() - } - - setSendEnabled(true) - onToggleCurrency() - } - - private fun setAddressLineColor(@ColorRes colorRes: Int = R.color.zcashBlack_12) { - DrawableCompat.setTint( - binding.inputZcashAddress.background, - ContextCompat.getColor(mainActivity, colorRes) - ) - } - - fun formatUserInput() { - formatAmountInput() - formatAddressInput() - } - - private fun formatAmountInput() { - val value = binding.textValueHeader.text.toString().toDouble().absoluteValue - binding.textValueHeader.setText( - when { - value == 0.0 -> "0" - usdSelected -> usdFormatter.format(value) - else -> zecFormatter.format(value) - } - ) - } - - private fun formatAddressInput() { - val address = binding.inputZcashAddress.text - if(address.isNotEmpty() && address.length < R.integer.z_address_min_length.toAppInt()) setAddressError(R.string.send_error_address_too_short.toAppString()) - else setAddressError(null) - } - - private fun setAddressError(message: String?) { - if (message == null) { - setAddressLineColor() - binding.textAddressError.text = null - binding.textAddressError.visibility = View.GONE - binding.buttonSendZec.isEnabled = true - } else { - setAddressLineColor(R.color.zcashRed) - binding.textAddressError.text = message - binding.textAddressError.visibility = View.VISIBLE - binding.buttonSendZec.isEnabled = false - } - } - - private fun initDialog() { - binding.dialogSendBackground.setOnClickListener { - hideSendDialog() - } - binding.dialogSubmitButton.setOnClickListener { - if (DEV_MODE) submit() else onSendZec() - } } override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -208,7 +87,6 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { launch { sendPresenter.start() } - if(DEV_MODE) showSendDialog() } override fun onPause() { @@ -216,56 +94,137 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { sendPresenter.stop() } + + // + // SendView Implementation + // + override fun submit() { - submitNoAnimations() + mainActivity.navController.navigate(R.id.nav_home_fragment) } - private fun submitNoAnimations() { - mainActivity.navController.navigate( - R.id.nav_home_fragment, - null, - null, - FragmentNavigatorExtras(binding.dialogTextTitle to "transition_active_transaction_title") - ) + override fun setHeaders(isUsdSelected: Boolean, headerString: String, subheaderString: String) { + showCurrencySymbols(isUsdSelected) + setHeaderValue(headerString) + setSubheaderValue(subheaderString, isUsdSelected) } - fun submitWithSharedElements() { - var extras = with(binding) { - listOf(dialogSendBackground, dialogSendContents, dialogTextTitle, dialogTextAddress) - .map{ it to it.transitionName } - .let { FragmentNavigatorExtras(*it.toTypedArray()) } + override fun setHeaderValue(value: String) { + binding.textValueHeader.setText(value) + } + + @SuppressLint("SetTextI18n") // SetTextI18n lint logic has errors and does not recognize that the entire string contains variables, formatted per locale and loaded from string resources. + override fun setSubheaderValue(value: String, isUsdSelected: Boolean) { + val subheaderLabel = if (isUsdSelected) zec else usd + binding.textValueSubheader.text = "$value $subheaderLabel" //ignore SetTextI18n error here because it is invalid + } + + override fun showSendDialog(zecString: String, usdString: String, toAddress: String, hasMemo: Boolean) { + hideKeyboard() + setSendEnabled(false) // partially because we need to lower the button elevation + binding.dialogTextTitle.text = getString(R.string.send_dialog_title, zecString, zec, usdString) + binding.dialogTextAddress.text = toAddress + binding.dialogTextMemoIncluded.visibility = if(hasMemo) View.VISIBLE else View.GONE + binding.groupDialogSend.visibility = View.VISIBLE + } + + override fun updateBalance(old: Long, new: Long) { + // TODO: use a formatted string resource here + val availableTextSpan = "${new.convertZatoshiToZecString(8)} $zec Available".toSpannable() + availableTextSpan.setSpan(ForegroundColorSpan(R.color.colorPrimary.toAppColor()), availableTextSpan.length - "Available".length, availableTextSpan.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + availableTextSpan.setSpan(StyleSpan(Typeface.BOLD), 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + binding.textZecValueAvailable.text = availableTextSpan + } + + + // + // ScanFragment.BarcodeCallback implemenation + // + + override fun onBarcodeScanned(value: String) { + exitScanMode() + binding.inputZcashAddress.setText(value) + validateAddressInput() + } + + + // + // Internal View Logic + // + + /** + * Initialize view logic only. Click listeners, text change handlers and tooltips. + */ + private fun init() { + /* Presenter calls */ + + binding.imageSwapCurrency.setOnClickListener { + sendPresenter.toggleCurrency() } -// val extras = FragmentNavigatorExtras( -// binding.dialogSendContents to binding.dialogSendContents.transitionName, -// binding.dialogTextTitle to getString(R.string.transition_active_transaction_title), -// binding.dialogTextAddress to getString(R.string.transition_active_transaction_address), -// binding.dialogSendBackground to getString(R.string.transition_active_transaction_background) -// ) - mainActivity.navController.navigate(R.id.nav_home_fragment, - null, - null, - extras) + binding.textValueHeader.apply { + afterTextChanged { + sendPresenter.headerUpdating(it) + } + } + + binding.buttonSendZec.setOnClickListener { + sendPresenter.sendPressed() + } + + /* 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)) + } + + binding.imageAddressShortcut?.apply { + if (BuildConfig.DEBUG) { + visibility = View.VISIBLE + TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_address_shortcut)) + setOnClickListener(::onPasteShortcutAddress) + } else { + visibility = View.GONE + } + } + + 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) } - @SuppressLint("SetTextI18n") - fun onToggleCurrency() { - view?.findFocus()?.clearFocus() - formatUserInput() - val isInitiallyUsd = usdSelected // hold this value because we modify visibility here and that's what the value is based on - val subHeaderValue = binding.textValueSubheader.text.toString().substringBefore(' ') - val currencyLabelAfterToggle = if (isInitiallyUsd) usd else zec // what is selected is about to move to the subheader where the currency is labelled - - binding.textValueSubheader.post { - binding.textValueSubheader.text = "${binding.textValueHeader.text} $currencyLabelAfterToggle" - binding.textValueHeader.setText(subHeaderValue) - } - if (isInitiallyUsd) { - binding.groupZecSelected.visibility = View.VISIBLE - binding.groupUsdSelected.visibility = View.GONE + private fun showCurrencySymbols(isUsdSelected: Boolean) { + // visibility has some kind of bug that appears to be related to layout groups. So using alpha instead since our API level is high enough to support that + if (isUsdSelected) { + binding.textDollarSymbolHeader.alpha = 1.0f + binding.imageZecSymbolSubheader.alpha = 1.0f + binding.imageZecSymbolHeader.alpha = 0.0f + binding.textDollarSymbolSubheader.alpha = 0.0f } else { - binding.groupZecSelected.visibility = View.GONE - binding.groupUsdSelected.visibility = View.VISIBLE + binding.imageZecSymbolHeader.alpha = 1.0f + binding.textDollarSymbolSubheader.alpha = 1.0f + binding.textDollarSymbolHeader.alpha = 0.0f + binding.imageZecSymbolSubheader.alpha = 0.0f } } @@ -274,72 +233,44 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { val fragment = ScanFragment() val ft = childFragmentManager.beginTransaction() .add(R.id.camera_placeholder, fragment, "camera_fragment") + .addToBackStack("camera_fragment_scanning") .commit() -// val intent = Intent(mainActivity, CameraQrScanner::class.java) -// mainActivity.startActivity(intent) -// qrCodeScanner.scanBarcode { barcode: Result -> -// if (barcode.isSuccess) { -// binding.inputZcashAddress.setText(barcode.getOrThrow()) -// formatAddressInput() -// } else { -// Toaster.short("failed to scan QR code") -// } -// } + + binding.groupHiddenDuringScan.visibility = View.INVISIBLE + binding.buttonCancelScan.apply { + visibility = View.VISIBLE + animate().alpha(1.0f).apply { + duration = 3000L + } + setOnClickListener { + exitScanMode() + } + } } // 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. private fun onPasteShortcutAddress(view: View) { view.context.alert(R.string.send_alert_shortcut_clicked) { binding.inputZcashAddress.setText(SampleProperties.wallet.defaultSendAddress) - setAddressError(null) + validateAddressInput() hideKeyboard() } } - override fun updateBalance(old: Long, new: Long) { - val zecBalance = new / 100000000.0 - val usdBalance = zecBalance * SampleProperties.USD_PER_ZEC - val availableZecFormatter = DecimalFormat("#.########") - // TODO: use a formatted string resource here - val availableTextSpan = "${availableZecFormatter.format(zecBalance)} $zec Available".toSpannable() - availableTextSpan.setSpan(ForegroundColorSpan(R.color.colorPrimary.toAppColor()), availableTextSpan.length - "Available".length, availableTextSpan.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - availableTextSpan.setSpan(StyleSpan(Typeface.BOLD), 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.textZecValueAvailable.text = availableTextSpan - } - private fun onSendZec() { setSendEnabled(false) -// val currency = if(zecSelected) "ZEC" else "USD" -// Toaster.short("sending ${text_value_header.text} $currency...") - - //TODO: convert and use only zec amount -// val amount = text_value_header.text.toString().toDouble() -// val address = input_zcash_address.text.toString() - val amount = 0.0018 - val address = "ztestsapling1fg82ar8y8whjfd52l0xcq0w3n7nn7cask2scp9rp27njeurr72ychvud57s9tu90fdqgwdt07lg" - sendPresenter.sendToAddress(amount, address) + sendPresenter.sendFunds() } - - // - // Internal View Logic - // - - private fun showSendDialog() { - hideKeyboard() - - val address = binding.inputZcashAddress.text - val headerString = binding.textValueHeader.text.toString() - val subheaderString = binding.textValueSubheader.text.toString().substringBefore(' ') - val zecString = if(usdSelected) subheaderString else headerString - val usdString = if(usdSelected) headerString else subheaderString - val memo = binding.textAreaMemo.text.toString().trim() - - setSendEnabled(false) // partially because we need to lower the button elevation - binding.dialogTextTitle.text = getString(R.string.send_dialog_title, zecString, zec, usdString) - binding.dialogTextAddress.text = address - binding.dialogTextMemoIncluded.visibility = if(memo.isNotEmpty()) View.VISIBLE else View.GONE - binding.groupDialogSend.visibility = View.VISIBLE + private fun exitScanMode() { + val cameraFragment = childFragmentManager.findFragmentByTag("camera_fragment") + if (cameraFragment != null) { + val ft = childFragmentManager.beginTransaction() + .remove(cameraFragment) + .commit() + } + binding.buttonCancelScan.visibility = View.GONE + binding.groupHiddenDuringScan.visibility = View.VISIBLE } private fun hideKeyboard() { @@ -352,16 +283,116 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { 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 - if (isEnabled) { - binding.buttonSendZec.text = "send $zec" -// binding.progressSend.visibility = View.GONE + } + + private fun setAddressError(message: String?) { + if (message == null) { + setAddressLineColor() + binding.textAddressError.text = null + binding.textAddressError.visibility = View.GONE } else { - binding.buttonSendZec.text = "sending..." -// binding.progressSend.visibility = View.VISIBLE + setAddressLineColor(R.color.zcashRed) + binding.textAddressError.text = message + binding.textAddressError.visibility = View.VISIBLE + setSendEnabled(false) } } + + 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") + setSendEnabled(false) + setAmountError(true) + false + } + } + + + + + +// TODO: come back to this test code later and fix the shared element transitions +// +// fun submitWithSharedElements() { +// var extras = with(binding) { +// listOf(dialogSendBackground, dialogSendContents, dialogTextTitle, dialogTextAddress) +// .map{ it to it.transitionName } +// .let { FragmentNavigatorExtras(*it.toTypedArray()) } +// } +// val extras = FragmentNavigatorExtras( +// binding.dialogSendContents to binding.dialogSendContents.transitionName, +// binding.dialogTextTitle to getString(R.string.transition_active_transaction_title), +// binding.dialogTextAddress to getString(R.string.transition_active_transaction_address), +// binding.dialogSendBackground to getString(R.string.transition_active_transaction_background) +// ) +// +// mainActivity.navController.navigate(R.id.nav_home_fragment, +// null, +// null, +// extras) +// } + + } 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 83b548c..304f971 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,14 +1,16 @@ package cash.z.android.wallet.ui.presenter import android.util.Log +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.entity.Transaction +import cash.z.wallet.sdk.ext.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.launch +import java.math.BigDecimal class SendPresenter( private val view: SendView, @@ -17,13 +19,25 @@ class SendPresenter( interface SendView : PresenterView { fun updateBalance(old: Long, new: Long) + fun setHeaders(isUsdSelected: Boolean, headerString: String, subheaderString: String) + 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() } private var balanceJob: Job? = null + var sendUiModel = SendUiModel() + + // + // 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() with(view) { balanceJob = launchBalanceBinder(synchronizer.balance()) } @@ -35,27 +49,117 @@ class SendPresenter( } fun CoroutineScope.launchBalanceBinder(channel: ReceiveChannel) = launch { - var old: Long? = null Log.e("@TWIG-v", "send balance binder starting!") for (new in channel) { Log.e("@TWIG-v", "send polled a balance item") - bind(old, new).also { old = new } + bind(new) } Log.e("@TWIG-v", "send balance binder exiting!") } - fun sendToAddress(value: Double, toAddress: String) { + + // + // Public API + // + + fun sendFunds() { //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 { - val zatoshi = Math.round(value * 1e8) - synchronizer.sendToAddress(zatoshi, toAddress) + synchronizer.sendToAddress(sendUiModel.zecValue!!, sendUiModel.toAddress) } view.submit() } - fun bind(old: Long?, new: Long) { - Log.e("@TWIG-v", "binding balance of $new") - view.updateBalance(old ?: 0L, new) + /** + * Called when the user has tapped on the button for toggling currency, swapping zec for usd + */ + fun toggleCurrency() { + view.validateUserInput() + 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() + ) + } } -} + + /** + * 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. + */ + fun headerUpdating(headerValue: String) { + headerValue.safelyConvertToBigDecimal()?.let { headerValueAsDecimal -> + val subheaderValue = headerValueAsDecimal.convertCurrency(SampleProperties.USD_PER_ZEC, sendUiModel.isUsdSelected) + + // subheader string contains opposite currency of the selected one. so if usd is selected, format the subheader as zec + val subheaderString = if(sendUiModel.isUsdSelected) subheaderValue.toZecString() else subheaderValue.toUsdString() + + view.setSubheaderValue(subheaderString, sendUiModel.isUsdSelected) + } + } + + fun sendPressed() { + with(sendUiModel) { + view.showSendDialog( + zecString = zecValue.convertZatoshiToZecString(), + usdString = usdValue.toUsdString(), + toAddress = toAddress, + hasMemo = !memo.isBlank() + ) + } + } + + fun headerValidated(amount: BigDecimal) { + with(sendUiModel) { + if (isUsdSelected) { + 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) + view.setHeaders(sendUiModel.isUsdSelected, headerString, subheaderString) + } else { + 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") + view.setHeaders(sendUiModel.isUsdSelected, headerString, subheaderString) + } + } + } + + fun addressValidated(address: String) { + sendUiModel = sendUiModel.copy(toAddress = address) + } + + /** + * 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 bind(newZecBalance: Long) { + if (newZecBalance >= 0) { + Log.e("@TWIG-v", "binding balance of $newZecBalance") + val old = sendUiModel.zecValue + sendUiModel = sendUiModel.copy(zecValue = newZecBalance) + view.updateBalance(old ?: 0L, newZecBalance) + } + } + + data class SendUiModel( + val isUsdSelected: Boolean = true, + val zecValue: Long? = null, + val usdValue: BigDecimal = BigDecimal.ZERO, + val toAddress: String = "", + val memo: String = "" + ) +} \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan.xml b/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan.xml index 0ee4f52..a7ea557 100644 --- a/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan.xml +++ b/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan.xml @@ -1,8 +1,19 @@ - + + android:layout_height="match_parent" + app:autoFocus="true" + app:facing="back" + app:flash="auto"> + + \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/res/layout/fragment_send.xml b/zcash-android-wallet-app/app/src/main/res/layout/fragment_send.xml index aa6f85a..9877f3e 100644 --- a/zcash-android-wallet-app/app/src/main/res/layout/fragment_send.xml +++ b/zcash-android-wallet-app/app/src/main/res/layout/fragment_send.xml @@ -82,14 +82,20 @@ app:layout_constraintTop_toBottomOf="@id/background_header" app:layout_constraintVertical_chainStyle="spread_inside" /> - +