zcash-android-wallet-poc/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/HomeFragment.kt

647 lines
26 KiB
Kotlin

package cash.z.android.wallet.ui.fragment
import android.os.Bundle
import android.text.SpannableString
import android.text.Spanned
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.transition.Transition
import androidx.transition.TransitionInflater
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.ui.adapter.TransactionAdapter
import cash.z.android.wallet.ui.presenter.HomePresenter
import cash.z.android.wallet.ui.util.AlternatingRowColorDecoration
import cash.z.android.wallet.ui.util.LottieLooper
import cash.z.android.wallet.ui.util.TopAlignedSpan
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.*
import com.google.android.material.snackbar.Snackbar
import com.leinardi.android.speeddial.SpeedDialActionItem
import dagger.Module
import dagger.android.ContributesAndroidInjector
import kotlinx.coroutines.launch
import kotlin.random.Random
import kotlin.random.nextLong
/**
* Fragment representing the home screen of the app. This is the screen most often seen by the user when launching the
* application.
*/
class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomePresenter.HomeView {
private lateinit var homePresenter: HomePresenter
private lateinit var binding: FragmentHomeBinding
private lateinit var zcashLogoAnimation: LottieLooper
private var snackbar: Snackbar? = null
private var viewsInitialized = false
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewsInitialized = false
// setupSharedElementTransitions()
return DataBindingUtil.inflate<FragmentHomeBinding>(
inflater, R.layout.fragment_home, container, false
).let {
binding = it
it.root
}
}
private fun setupSharedElementTransitions() {
TransitionInflater.from(mainActivity).inflateTransition(R.transition.transition_zec_sent).apply {
duration = 3000L
addListener(HomeTransitionListener())
this@HomeFragment.sharedElementEnterTransition = this
this@HomeFragment.sharedElementReturnTransition = this
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initTemp()
init()
// launch {
// Log.e("TWIG", "deciding whether to show first run")
// val extraDelay = measureTimeMillis {
// setFirstRunShown(mainActivity.synchronizer.isFirstRun() || mainActivity.synchronizer.isOutOfSync())
// }
// Log.e("TWIG", "done deciding whether to show first run in $extraDelay ms. Was that worth it? Or should we toggle a boolean in the application class?")
// }
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
with(mainActivity) {
setSupportActionBar(binding.includeHeader.homeToolbar)
setupNavigation()
supportActionBar?.setTitle(R.string.destination_title_home)
}
initFab()
homePresenter = HomePresenter(this, mainActivity.synchronizer)
binding.includeContent.recyclerTransactions.apply {
layoutManager = LinearLayoutManager(activity, RecyclerView.VERTICAL, false)
adapter = TransactionAdapter()
addItemDecoration(AlternatingRowColorDecoration())
}
}
override fun onResume() {
super.onResume()
launch {
homePresenter.start()
}
}
override fun onPause() {
super.onPause()
homePresenter.stop()
binding.lottieZcashBadge.cancelAnimation()
}
//
// View API
//
fun setContentViewShown(isShown: Boolean) {
with(binding.includeContent) {
groupEmptyViewItems.visibility = if (isShown) View.GONE else View.VISIBLE
groupContentViewItems.visibility = if (isShown) View.VISIBLE else View.GONE
}
toggleViews(!isShown)
}
override fun onRefresh() {
setRefreshAnimationPlaying(true).also { Log.e("TWIG-a", "refresh true from onRefresh") }
with(binding.includeContent.refreshLayout) {
isRefreshing = false
val fauxRefresh = Random.nextLong(750L..3000L)
postDelayed({
setRefreshAnimationPlaying(false).also { Log.e("TWIG-a", "refresh false from onRefresh") }
}, fauxRefresh)
}
}
fun setRefreshAnimationPlaying(isPlaying: Boolean) {
Log.e("TWIG-a", "set refresh to: $isPlaying for $zcashLogoAnimation")
if (isPlaying) {
zcashLogoAnimation.start()
} else {
zcashLogoAnimation.stop()
view?.postDelayed({
zcashLogoAnimation.stop()
}, 500L)
}
}
//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.convertZatoshiToZec()
setZecValue(zecValue.toZecString(3))
setUsdValue(zecValue.convertZecToUsd(SampleProperties.USD_PER_ZEC).toUsdString())
onContentRefreshComplete(new)
}
override fun setTransactions(transactions: List<WalletTransaction>) {
with (binding.includeContent.recyclerTransactions) {
(adapter as TransactionAdapter).submitList(transactions)
postDelayed({
smoothScrollToPosition(0)
}, 100L)
if (binding.includeFirstRun.visibility == View.VISIBLE) setFirstRunShown(false)
}
}
override fun showProgress(progress: Int) {
if(progress >= 100) {
view?.postDelayed({
onInitialLoadComplete()
}, 3000L)
} else {
setRefreshAnimationPlaying(true).also { Log.e("TWIG-a", "refresh true from showProgress") }
binding.includeContent.textEmptyWalletMessage.setText(R.string.home_empty_wallet_updating)
}
// snackbar.showOk(view!!, "progress: $progress")
// TODO: improve this with Lottie animation. but for now just use the empty view for downloading...
// var hasEmptyViews = group_empty_view_items.visibility == View.VISIBLE
// if(!viewsInitialized) toggleViews(true)
//
// val message = if(progress >= 100) "Download complete! Processing blocks..." else "Downloading remaining blocks ($progress%)"
//// text_wallet_message.text = message
//
// if (snackbar == null && progress <= 50) {
// snackbar = Snackbar.make(view!!, "$message", Snackbar.LENGTH_INDEFINITE)
// .setAction("OK") {
// snackbar?.dismiss()
// }
// snackbar?.show()
// } else {
// snackbar?.setText(message)
// if(snackbar?.isShownOrQueued != true) snackbar?.show()
// }
}
private fun onInitialLoadComplete() {
val isEmpty = (binding.includeContent.recyclerTransactions?.adapter?.itemCount ?: 0).let { it == 0 }
Log.e("TWIG-t", "onInitialLoadComplete and isEmpty == $isEmpty")
setContentViewShown(!isEmpty)
if (isEmpty) {
binding.includeContent.textEmptyWalletMessage.setText(R.string.home_empty_wallet)
}
setRefreshAnimationPlaying(false).also { Log.e("TWIG-a", "refresh false from onInitialLoadComplete") }
}
override fun setActiveTransactions(activeTransactionMap: Map<ActiveTransaction, TransactionState>) {
if (activeTransactionMap.isEmpty()) {
setActiveTransactionsShown(false)
return
}
val transactions = activeTransactionMap.entries.toTypedArray()
// primary is the last one that was inserted
val primaryEntry = transactions[transactions.size - 1]
updatePrimaryTransaction(primaryEntry.key, primaryEntry.value)
// TODO: update remaining transactions
}
override fun onCancelledTooLate() {
snackbar = snackbar.showOk(view!!, "Oops! It was too late to cancel!")
}
private fun updatePrimaryTransaction(transaction: ActiveTransaction, transactionState: TransactionState) {
setActiveTransactionsShown(true)
Log.e("TWIG", "setting transaction state to ${transactionState::class.simpleName}")
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.convertZatoshiToZecString(3)} ZEC"
subtitle = "to ${(transaction as ActiveSendTransaction).toAddress}"
setTransactionActive(transaction, true)
}
TransactionState.SendingToNetwork -> {
title = "Sending Transaction"
subtitle = "to ${(transaction as ActiveSendTransaction).toAddress}"
binding.includeContent.textActiveTransactionValue.text = "${transaction.value/1000L}"
binding.includeContent.textActiveTransactionValue.visibility = View.VISIBLE
binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE
}
is TransactionState.Failure -> {
binding.includeContent.lottieActiveTransaction.setAnimation(R.raw.lottie_send_failure)
binding.includeContent.lottieActiveTransaction.playAnimation()
title = "Failed"
subtitle = when(transactionState.failedStep) {
TransactionState.Creating -> "Failed to create transaction"
TransactionState.SendingToNetwork -> "Failed to submit transaction to the network"
else -> "Unrecoginzed error"
}
binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE
binding.includeContent.textActiveTransactionValue.visibility = View.GONE
setTransactionActive(transaction, false)
setActiveTransactionsShown(false, 10000L)
}
is TransactionState.AwaitingConfirmations -> {
if (transactionState.confirmationCount < 1) {
binding.includeContent.lottieActiveTransaction.setAnimation(R.raw.lottie_send_success)
binding.includeContent.lottieActiveTransaction.playAnimation()
title = "ZEC Sent"
subtitle = "Today at 2:11pm"
binding.includeContent.textActiveTransactionValue.text = transaction.value.convertZatoshiToZecString(3)
binding.includeContent.textActiveTransactionValue.visibility = View.VISIBLE
binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE
} else {
title = "Confirmation Received"
subtitle = "Today at 2:12pm"
// take it out of the list in a bit and skip counting confirmation animation for now (i.e. one is enough)
setActiveTransactionsShown(false, 3000L)
}
}
is TransactionState.Cancelled -> {
title = binding.includeContent.textActiveTransactionTitle.text.toString()
subtitle = binding.includeContent.textActiveTransactionSubtitle.text.toString()
setTransactionActive(transaction, false)
}
}
binding.includeContent.textActiveTransactionTitle.text = title
binding.includeContent.textActiveTransactionSubtitle.text = subtitle
}
//
// Private View API
//
private fun setActiveTransactionsShown(isShown: Boolean, delay: Long = 0L) {
Log.e("TWIG-a", "setActiveTransactionsShown: $isShown")
binding.includeContent.headerActiveTransaction.postDelayed({
binding.includeContent.groupActiveTransactionItems.visibility = if (isShown) View.VISIBLE else View.GONE
}, delay)
}
private fun setFirstRunShown(isShown: Boolean) {
binding.includeFirstRun.visibility = if (isShown) View.VISIBLE else View.GONE
mainActivity.setDrawerLocked(isShown)
binding.sdFab.visibility = if (!isShown) View.VISIBLE else View.GONE
binding.lottieZcashBadge.visibility = if(!isShown) View.VISIBLE else View.GONE
}
/**
* General initialization called during onViewCreated. Mostly responsible for applying the default empty state of
* the view, before any data or information is known.
*/
private fun init() {
zcashLogoAnimation = LottieLooper(binding.lottieZcashBadge, 20..47, 69)
binding.includeContent.buttonActiveTransactionCancel.setOnClickListener {
val transaction = it.tag as? ActiveSendTransaction
if (transaction != null) {
homePresenter.onCancelActiveTransaction(transaction)
} else {
Toaster.short("Error: unable to find transaction to cancel!")
}
}
binding.includeContent.refreshLayout.setProgressViewEndTarget(false, (38f * resources.displayMetrics.density).toInt())
with(binding.includeContent.refreshLayout) {
setOnRefreshListener(this@HomeFragment)
setColorSchemeColors(R.color.zcashBlack.toAppColor())
setProgressBackgroundColorSchemeColor(R.color.zcashYellow.toAppColor())
}
// hide content
setActiveTransactionsShown(false)
setContentViewShown(false)
binding.includeContent.textEmptyWalletMessage.setText(R.string.home_empty_wallet_updating)
setRefreshAnimationPlaying(true).also { Log.e("TWIG-a", "refresh true from init") }
}
// initialize the stuff that is temporary and needs to go ASAP
private fun initTemp() {
with(binding.includeHeader) {
headerFullViews = arrayOf(textBalanceUsd, textBalanceIncludesInfo, textBalanceZec, imageZecSymbolBalanceShadow, imageZecSymbolBalance)
headerEmptyViews = arrayOf(textBalanceZecInfo, textBalanceZecEmpty, imageZecSymbolBalanceShadowEmpty, imageZecSymbolBalanceEmpty)
headerFullViews.forEach { containerHomeHeader.removeView(it) }
headerEmptyViews.forEach { containerHomeHeader.removeView(it) }
binding.includeHeader.containerHomeHeader.visibility = View.INVISIBLE
}
// toggling determines visibility. hide it all.
binding.includeContent.groupEmptyViewItems.visibility = View.GONE
binding.includeContent.groupContentViewItems.visibility = View.GONE
}
/**
* Initialize the Fab button and all its action items. Should be called during onActivityCreated.
*/
private fun initFab() {
val speedDial = binding.sdFab
val nav = mainActivity.navController
HomeFab.values().forEach {
speedDial.addActionItem(it.createItem())
}
speedDial.setOnActionSelectedListener { item ->
if (item.id == R.id.fab_request) {
Toaster.short("off!")
setActiveTransactionsShown(false)
// setRefreshAnimationPlaying(false)
} else if (item.id == R.id.fab_receive) {
Toaster.short("on!")
setActiveTransactionsShown(true)
// setRefreshAnimationPlaying(true)
} else {
HomeFab.fromId(item.id)?.destination?.apply { nav.navigate(this) }
}
false
}
}
/**
* Helper for creating fablets--those little buttons that pop up when the fab is tapped.
*/
private val createItem: HomeFab.() -> SpeedDialActionItem = {
SpeedDialActionItem.Builder(id, icon)
.setFabBackgroundColor(bgColor.toAppColor())
.setFabImageTintColor(R.color.zcashWhite.toAppColor())
.setLabel(label.toAppString())
.setLabelClickable(true)
.create()
}
private fun setUsdValue(valueString: String) {
val hairSpace = "\u200A"
// val adjustedValue = "$$hairSpace$valueString"
val textSpan = SpannableString(valueString)
textSpan.setSpan(TopAlignedSpan(), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textSpan.setSpan(TopAlignedSpan(), valueString.length - 3, valueString.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.includeHeader.textBalanceUsd.text = textSpan
}
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.
// val hairSpace = "\u200A"
// val adjustedValue = "$hairSpace$valueString$hairSpace"
// text_balance_zec.text = adjustedValue
}
/**
* Called whenever the content has been refreshed on the screen. When it is time to show and hide things.
* If the balance goes to zero, the wallet is now empty so show the empty view.
* 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: 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
val situationHasChanged = !viewsInitialized || (isEmpty != wasEmpty)
Log.e("TWIG-t", "updateEmptyViews called with value: $value initialized: $viewsInitialized isEmpty: $isEmpty wasEmpty: $wasEmpty")
if (situationHasChanged) {
Log.e("TWIG-t", "The situation has changed! toggling views!")
setContentViewShown(!isEmpty)
if (!isEmpty) setFirstRunShown(false)
}
setRefreshAnimationPlaying(false).also { Log.e("TWIG-a", "refresh false from onContentRefreshComplete") }
binding.includeHeader.containerHomeHeader.visibility = View.VISIBLE
}
private fun onActiveTransactionTransitionStart() {
binding.includeContent.buttonActiveTransactionCancel.visibility = View.INVISIBLE
}
private fun onActiveTransactionTransitionEnd() {
// TODO: investigate if this fix is still required after getting transition animation working again
// fixes a bug where the translation gets lost, during animation. As a nice side effect, visually, it makes the view appear to settle in to position
binding.includeContent.headerActiveTransaction.translationZ = 10.0f
binding.includeContent.buttonActiveTransactionCancel.apply {
postDelayed({text = "cancel"}, 50L)
visibility = View.VISIBLE
}
}
private fun setTransactionActive(transaction: ActiveTransaction, isActive: Boolean) {
// TODO: get view for transaction, mostly likely keep a sparse array of these or something
if (isActive) {
binding.includeContent.buttonActiveTransactionCancel.setText(R.string.cancel)
binding.includeContent.buttonActiveTransactionCancel.isEnabled = true
binding.includeContent.buttonActiveTransactionCancel.tag = transaction
binding.includeContent.headerActiveTransaction.animate().apply {
translationZ(10f)
duration = 200L
interpolator = DecelerateInterpolator()
}
} else {
binding.includeContent.buttonActiveTransactionCancel.setText(R.string.cancelled)
binding.includeContent.buttonActiveTransactionCancel.isEnabled = false
binding.includeContent.buttonActiveTransactionCancel.tag = null
binding.includeContent.headerActiveTransaction.animate().apply {
translationZ(2f)
duration = 300L
interpolator = AccelerateInterpolator()
}
binding.includeContent.lottieActiveTransaction.cancelAnimation()
}
}
/**
* Defines the basic properties of each FAB button for use while initializing the FAB
*/
enum class HomeFab(
@IdRes val id:Int,
@DrawableRes val icon:Int,
@ColorRes val bgColor:Int,
@StringRes val label:Int,
@IdRes val destination:Int
) {
/* ordered by when they need to be added to the speed dial (i.e. reverse display order) */
REQUEST(
R.id.fab_request,
R.drawable.ic_receipt_24dp,
R.color.icon_request,
R.string.destination_menu_label_request,
R.id.nav_request_fragment
),
RECEIVE(
R.id.fab_receive,
R.drawable.ic_qrcode_24dp,
R.color.icon_receive,
R.string.destination_menu_label_receive,
R.id.nav_receive_fragment
),
SEND(
R.id.fab_send,
R.drawable.ic_menu_send,
R.color.icon_send,
R.string.destination_menu_label_send,
R.id.nav_send_fragment
);
companion object {
fun fromId(id: Int): HomeFab? = values().firstOrNull { it.id == id }
}
}
//
//
//
//// ---------------------------------------------------------------------------------------------------------------------
//// TODO: Delete these test functions
//// ---------------------------------------------------------------------------------------------------------------------
//
var empty = false
val delay = 50L
lateinit var headerEmptyViews: Array<View>
lateinit var headerFullViews: Array<View>
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 toggleViews(isEmpty: Boolean) {
Log.e("TWIG-t", "toggling views to isEmpty == $isEmpty")
var action: () -> Unit
if (isEmpty) {
action = {
binding.includeContent.groupEmptyViewItems.visibility = View.VISIBLE
binding.includeContent.groupContentViewItems.visibility = View.GONE
headerFullViews.forEach { binding.includeHeader.containerHomeHeader.removeView(it) }
headerEmptyViews.forEach {
tryIgnore {
binding.includeHeader.containerHomeHeader.addView(it)
}
}
}
} else {
action = {
binding.includeContent.groupEmptyViewItems.visibility = View.GONE
binding.includeContent.groupContentViewItems.visibility = View.VISIBLE
headerEmptyViews.forEach { binding.includeHeader.containerHomeHeader.removeView(it) }
headerFullViews.forEach {
tryIgnore {
binding.includeHeader.containerHomeHeader.addView(it)
}
}
}
}
view?.postDelayed({
binding.includeHeader.containerHomeHeader.visibility = View.VISIBLE
action()
viewsInitialized = true
}, delay)
// TODO: the motion layout does not begin in the right state for some reason. Debug this later.
view?.postDelayed(::forceRedraw, delay * 2)
}
// TODO: get rid of all of this and consider two different fragments for the header, instead
internal fun toggleViews2(isEmpty: Boolean) {
var action: () -> Unit
if (isEmpty) {
action = {
// group_empty_view_items.visibility = View.VISIBLE
// group_full_view_items.visibility = View.GONE
headerFullViews.forEach { binding.includeHeader.containerHomeHeader.removeView(it) }
headerEmptyViews.forEach {
tryIgnore {
binding.includeHeader.containerHomeHeader.addView(it)
}
}
}
} else {
action = {
// group_empty_view_items.visibility = View.GONE
// group_full_view_items.visibility = View.VISIBLE
headerEmptyViews.forEach { binding.includeHeader.containerHomeHeader.removeView(it) }
headerFullViews.forEach {
tryIgnore {
binding.includeHeader.containerHomeHeader.addView(it)
}
}
}
}
view?.postDelayed({
action()
viewsInitialized = true
}, delay)
// TODO: the motion layout does not begin in the right state for some reason. Debug this later.
view?.postDelayed(::forceRedraw, delay * 2)
}
// internal fun toggleValues(isEmpty: Boolean) {
// empty = isEmpty
// if(empty) {
// reduceValue()
// } else {
// increaseValue(Random.nextDouble(20.0, 100.0))
// }
// }
inner class HomeTransitionListener : Transition.TransitionListener {
override fun onTransitionStart(transition: Transition) {
}
override fun onTransitionEnd(transition: Transition) {
}
override fun onTransitionResume(transition: Transition) {}
override fun onTransitionPause(transition: Transition) {}
override fun onTransitionCancel(transition: Transition) {}
}
}
@Module
abstract class HomeFragmentModule {
@ContributesAndroidInjector
abstract fun contributeHomeFragment(): HomeFragment
}