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.activity.MainActivity import cash.z.android.wallet.ui.adapter.TransactionAdapter import cash.z.android.wallet.ui.presenter.HomePresenter import cash.z.android.wallet.ui.presenter.Presenter import cash.z.android.wallet.ui.util.AlternatingRowColorDecoration import cash.z.android.wallet.ui.util.AnimatorCompleteListener 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.* import cash.z.wallet.sdk.ext.* import com.google.android.material.snackbar.Snackbar import com.leinardi.android.speeddial.SpeedDialActionItem import dagger.Binds import dagger.Module import dagger.Provides import dagger.android.ContributesAndroidInjector import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton 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 { @Inject 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( 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() } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) mainActivity?.setToolbarShown(false) mainActivity?.setDrawerLocked(false) initFab() binding.includeContent.recyclerTransactions.apply { layoutManager = LinearLayoutManager(activity, RecyclerView.VERTICAL, false) adapter = TransactionAdapter() addItemDecoration(AlternatingRowColorDecoration()) } binding.includeContent.textTransactionHeaderSeeAll.setOnClickListener { mainActivity?.navController?.navigate(R.id.nav_history_fragment) } } 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 { twig("refresh true from onRefresh") } with(binding.includeContent.refreshLayout) { isRefreshing = false val fauxRefresh = Random.nextLong(750L..3000L) postDelayed({ setRefreshAnimationPlaying(false).also { twig("refresh false from onRefresh") } }, fauxRefresh) } } fun setRefreshAnimationPlaying(isPlaying: Boolean) { twig("set refresh to: $isPlaying for $zcashLogoAnimation") if (isPlaying) { zcashLogoAnimation.start() } else { zcashLogoAnimation.stop() } } //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) { val recent = if(transactions.size > 12) transactions.subList(0, 12) else transactions with (binding.includeContent.recyclerTransactions) { (adapter as TransactionAdapter).submitList(recent) postDelayed({ smoothScrollToPosition(0) }, 100L) } if (recent.size != transactions.size) { binding.includeContent.textTransactionHeaderSeeAll.visibility = View.VISIBLE } } private fun onInitialLoadComplete() { val isEmpty = (binding.includeContent.recyclerTransactions?.adapter?.itemCount ?: 0).let { it == 0 } twig("onInitialLoadComplete and isEmpty == $isEmpty") setContentViewShown(!isEmpty) if (isEmpty) { binding.includeContent.textEmptyWalletMessage.setText(R.string.home_empty_wallet) } setRefreshAnimationPlaying(false).also { twig("refresh false from onInitialLoadComplete") } } override fun setActiveTransactions(activeTransactionMap: Map) { 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) { twig("setting transaction state to ${transactionState::class.simpleName}") var title = binding.includeContent.textActiveTransactionTitle.text?.toString() ?: "" var subtitle = binding.includeContent.textActiveTransactionSubtitle.text?.toString() ?: "" var isShown = binding.includeContent.textActiveTransactionHeader.visibility == View.VISIBLE var isShownDelay = 10L when (transactionState) { TransactionState.Creating -> { binding.includeContent.headerActiveTransaction.visibility = View.VISIBLE title = "Preparing ${transaction.value.convertZatoshiToZecString(3)} ZEC" subtitle = "to ${(transaction as ActiveSendTransaction).toAddress.truncate()}" setTransactionActive(transaction, true) isShown = true } TransactionState.SendingToNetwork -> { title = "Sending Transaction" subtitle = "to ${(transaction as ActiveSendTransaction).toAddress.truncate()}" binding.includeContent.textActiveTransactionValue.text = "${transaction.value.convertZatoshiToZecString(3)}" binding.includeContent.textActiveTransactionValue.visibility = View.VISIBLE binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE setTransactionActive(transaction, true) isShown = true } 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) isShown = false isShownDelay = 10_000L } 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 isShown = true } else if (transactionState.confirmationCount > 1) { isShown = false } else { title = "Confirmation Received" subtitle = "Today at 2:12pm" isShown = false; isShownDelay = 5_000L // take it out of the list in a bit and skip counting confirmation animation for now (i.e. one is enough) } } is TransactionState.Cancelled -> { title = binding.includeContent.textActiveTransactionTitle.text.toString() subtitle = binding.includeContent.textActiveTransactionSubtitle.text.toString() setTransactionActive(transaction, false) isShown = false isShownDelay = 10_000L } else -> { Log.e(javaClass.simpleName, "Warning: unrecognized transaction state $transactionState is being ignored") return } } binding.includeContent.textActiveTransactionTitle.text = title binding.includeContent.textActiveTransactionSubtitle.text = subtitle setActiveTransactionsShown(isShown, isShownDelay) } // // Private View API // private fun setActiveTransactionsShown(isShown: Boolean, delay: Long = 0L) { binding.includeContent.headerActiveTransaction.postDelayed({ // do not animate if visibility is already in the right state // binding.includeContent.headerActiveTransaction.animate().alpha(if(isShown) 1f else 0f).setDuration(250).setListener( AnimatorCompleteListener{ binding.includeContent.groupActiveTransactionItems.visibility = if (isShown) View.VISIBLE else View.GONE } // ) }, delay) } /** * 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 setContentViewShown(false) binding.includeContent.textEmptyWalletMessage.setText(R.string.home_empty_wallet_updating) setRefreshAnimationPlaying(true).also { twig("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 -> 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(value: String) { val valueString = String.format("$ $value") // 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) twig("updateEmptyViews called with value: $value initialized: $viewsInitialized isEmpty: $isEmpty wasEmpty: $wasEmpty") if (situationHasChanged) { twig("The situation has changed! toggling views!") setContentViewShown(!isEmpty) } setRefreshAnimationPlaying(false).also { twig("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 lateinit var headerFullViews: Array 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) { twig("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 @Binds @Singleton abstract fun providePresenter(homePresenter: HomePresenter): Presenter }