Implemented homescreen.

- Added logic for numberpad and send button
  - Added logic for starting sync and displaying progress
This commit is contained in:
Kevin Gorham 2019-12-23 14:19:47 -05:00
parent fa4415ae99
commit f81c6b2dff
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
4 changed files with 378 additions and 77 deletions

View File

@ -1,89 +1,255 @@
package cash.z.ecc.android.ui.home
import android.content.Context
import android.content.res.ColorStateList
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentHomeBinding
import cash.z.ecc.android.di.annotation.FragmentScope
import cash.z.ecc.android.ext.disabledIf
import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.onClickNavTo
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.*
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED
import cash.z.wallet.sdk.SdkSynchronizer
import cash.z.wallet.sdk.Synchronizer.Status.SYNCING
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.convertZecToZatoshi
import cash.z.wallet.sdk.ext.safelyConvertToBigDecimal
import cash.z.wallet.sdk.ext.twig
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.Module
import dagger.android.ContributesAndroidInjector
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
private lateinit var numberPad: List<TextView>
private lateinit var uiModel: HomeViewModel.UiModel
@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
private val walletSetup: WalletSetupViewModel by activityViewModels { viewModelFactory }
private val viewModel: HomeViewModel by activityViewModels { viewModelFactory }
private val _typedChars = ConflatedBroadcastChannel<Char>()
private val typedChars = _typedChars.asFlow()
override fun inflate(inflater: LayoutInflater): FragmentHomeBinding =
FragmentHomeBinding.inflate(inflater)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding) {
numberPad = arrayListOf(
buttonNumberPad0,
buttonNumberPad1,
buttonNumberPad2,
buttonNumberPad3,
buttonNumberPad4,
buttonNumberPad5,
buttonNumberPad6,
buttonNumberPad7,
buttonNumberPad8,
buttonNumberPad9,
buttonNumberPadDecimal,
buttonNumberPadBack
)
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_receive)
iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
hitAreaScan.onClickNavTo(R.id.action_nav_home_to_nav_send)
textBannerAction.setOnClickListener {
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
}
}
// TODO: trigger this from presenter
onNoFunds()
}
//
// LifeCycle
//
override fun onAttach(context: Context) {
twig("HomeFragment.onAttach")
super.onAttach(context)
// call initSync either now or later (after initializing DBs with newly created seed)
walletSetup.checkSeed().onEach {
twig("Checking seed")
when(it) {
NO_SEED -> {
twig("Seed not found, therefore, launching seed creation flow")
// interact with user to create, backup and verify seed
mainActivity?.navController?.navigate(R.id.action_nav_home_to_create_wallet)
// leads to a call to initSync(), later (after accounts are created from seed)
}
else -> {
twig("Found seed. Re-opening existing wallet")
mainActivity?.initSync()
}
}
}.launchIn(lifecycleScope)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
twig("HomeFragment.onViewCreated uiModel: ${::uiModel.isInitialized} saved: ${savedInstanceState != null}")
with(binding) {
numberPad = arrayListOf(
buttonNumberPad0.asKey(),
buttonNumberPad1.asKey(),
buttonNumberPad2.asKey(),
buttonNumberPad3.asKey(),
buttonNumberPad4.asKey(),
buttonNumberPad5.asKey(),
buttonNumberPad6.asKey(),
buttonNumberPad7.asKey(),
buttonNumberPad8.asKey(),
buttonNumberPad9.asKey(),
buttonNumberPadDecimal.asKey(),
buttonNumberPadBack.asKey()
)
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_receive)
iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
// hitAreaScan.onClickNavTo(R.id.action_nav_home_to_nav_send)
textBannerAction.setOnClickListener {
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
}
buttonSend.setOnClickListener {
onSend()
}
}
if (::uiModel.isInitialized) {
twig("uiModel exists!")
onModelUpdated(HomeViewModel.UiModel(), uiModel)
} else {
twig("uiModel does not exist!")
mainActivity?.onSyncInit {
viewModel.initialize(mainActivity!!.synchronizer, typedChars)
}
}
}
override fun onResume() {
super.onResume()
twig("HomeFragment.onResume resumeScope.isActive: ${resumedScope.isActive} $resumedScope")
viewModel.uiModels.scanReduce { old, new ->
onModelUpdated(old, new)
new
}.catch { e ->
twig("exception while processing uiModels $e")
}.launchIn(resumedScope)
// TODO: see if there is a better way to trigger a refresh of the uiModel on resume
// the latest one should just be in the viewmodel and we should just "resubscribe"
// but for some reason, this doesn't always happen, which kind of defeats the purpose
// of having a cold stream in the view model
resumedScope.launch {
(mainActivity!!.synchronizer as SdkSynchronizer).refreshBalance()
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
twig("HomeFragment.onSaveInstanceState")
if (::uiModel.isInitialized) {
outState.putParcelable("uiModel", uiModel)
}
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
savedInstanceState?.let { inState ->
twig("HomeFragment.onViewStateRestored")
onModelUpdated(HomeViewModel.UiModel(), inState.getParcelable("uiModel")!!)
}
}
//
// Public UI API
//
fun setSendEnabled(enabled: Boolean) {
binding.buttonSend.apply {
isEnabled = enabled
backgroundTintList = ColorStateList.valueOf( resources.getColor( if(enabled) R.color.colorPrimary else R.color.zcashWhite_24) )
}
}
fun setProgress(progress: Int) {
progress.let {
if (it < 100) {
setBanner("Downloading . . . $it%", NONE)
} else {
setBanner("Scanning . . .", NONE)
}
}
}
fun setSendAmount(amount: String) {
binding.textSendAmount.text = "\$$amount"
mainActivity?.sendViewModel?.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi()
binding.buttonSend.disabledIf(amount == "0")
}
fun setAvailable(availableBalance: Long = -1L, totalBalance: Long = -1L) {
val availableString = if (availableBalance < 0) "Updating" else availableBalance.convertZatoshiToZecString()
binding.textBalanceAvailable.text = availableString
binding.textBalanceDescription.apply {
goneIf(availableBalance < 0)
text = if (availableBalance != -1L && (availableBalance < totalBalance)) {
"(expecting +${(totalBalance - availableBalance).convertZatoshiToZecString()} ZEC in change)"
} else {
"(enter an amount to send)"
}
}
}
fun setSendText(buttonText: String = "Send Amount") {
binding.buttonSend.text = buttonText
}
fun setBanner(message: String = "", action: BannerAction = CLEAR) {
with(binding) {
val hasMessage = !message.isEmpty() || action != CLEAR
groupBalance.goneIf(hasMessage)
groupBanner.goneIf(!hasMessage)
layerLock.goneIf(!hasMessage)
textBannerMessage.text = message
textBannerAction.text = action.action
}
}
//
// Private UI Events
//
private fun onModelUpdated(old: HomeViewModel.UiModel, new: HomeViewModel.UiModel) {
twig(new.toString())
uiModel = new
if (old.pendingSend != new.pendingSend) {
setSendAmount(new.pendingSend)
}
// TODO: handle stopped and disconnected flows
if (new.status == SYNCING) onSyncing(new) else onSynced(new)
setSendEnabled(new.isSendEnabled)
}
private fun onSyncing(uiModel: HomeViewModel.UiModel) {
setProgress(uiModel.progress) // calls setBanner
setAvailable()
setSendText("Syncing Blockchain…")
}
private fun onSynced(uiModel: HomeViewModel.UiModel) {
if (!uiModel.hasFunds) {
onNoFunds()
} else {
setBanner("")
setAvailable(uiModel.availableBalance, uiModel.totalBalance)
setSendText()
}
}
private fun onSend() {
mainActivity?.navController?.navigate(R.id.action_nav_home_to_send)
}
private fun onBannerAction(action: BannerAction) {
when (action) {
LEARN_MORE -> {
FUND_NOW -> {
MaterialAlertDialogBuilder(activity)
.setMessage("To make full use of this wallet, deposit funds to your address or tap the faucet to trigger a tiny automatic deposit.\n\nFaucet funds are made available for the community by the community for testing. So please be kind enough to return what you borrow!")
.setTitle("No Balance")
@ -106,25 +272,19 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
}
private fun onNoFunds() {
setBanner("No Balance", LEARN_MORE)
setBanner("No Balance", FUND_NOW)
}
private fun setBanner(message: String = "", action: BannerAction = CLEAR) {
with(binding) {
val hasMessage = !message.isEmpty() || action != CLEAR
groupBalance.goneIf(hasMessage)
groupBanner.goneIf(!hasMessage)
layerLock.goneIf(!hasMessage)
textBannerMessage.text = message
textBannerAction.text = action.action
}
}
//
// Inner classes and extensions
//
enum class BannerAction(val action: String) {
LEARN_MORE("Learn More"),
FUND_NOW("Fund Now"),
CANCEL("Cancel"),
CLEAR("");
NONE(""),
CLEAR("clear");
companion object {
fun from(action: String?): BannerAction {
@ -135,6 +295,54 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
}
}
}
private fun TextView.asKey(): TextView {
val c = text[0]
setOnClickListener {
lifecycleScope.launch {
twig("CHAR TYPED: $c")
_typedChars.send(c)
}
}
return this
}
// TODO: remove these troubleshooting logs
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
twig("HomeFragment.onCreate")
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
twig("HomeFragment.onActivityCreated")
}
override fun onStart() {
super.onStart()
twig("HomeFragment.onStart")
}
override fun onPause() {
super.onPause()
twig("HomeFragment.onPause resumeScope.isActive: ${resumedScope.isActive}")
}
override fun onStop() {
super.onStop()
twig("HomeFragment.onStop")
}
override fun onDestroyView() {
super.onDestroyView()
twig("HomeFragment.onDestroyView")
}
override fun onDestroy() {
super.onDestroy()
twig("HomeFragment.onDestroy")
}
override fun onDetach() {
super.onDetach()
twig("HomeFragment.onDetach")
}
}

View File

@ -0,0 +1,72 @@
package cash.z.ecc.android.ui.home
import android.os.Parcelable
import androidx.lifecycle.ViewModel
import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.Synchronizer.Status.DISCONNECTED
import cash.z.wallet.sdk.Synchronizer.Status.SYNCED
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.ZcashSdk.MINERS_FEE_ZATOSHI
import cash.z.wallet.sdk.ext.ZcashSdk.ZATOSHI_PER_ZEC
import cash.z.wallet.sdk.ext.twig
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.flow.*
import javax.inject.Inject
class HomeViewModel @Inject constructor() : ViewModel() {
lateinit var uiModels: Flow<UiModel>
fun initialize(
synchronizer: Synchronizer,
typedChars: Flow<Char>
) {
twig("init called")
val zec = typedChars.scan("0") { acc, c ->
when {
// no-op cases
acc == "0" && c == '0'
|| (c == '<' && acc == "0")
|| (c == '.' && acc.contains('.')) -> {twig("triggered: 1 acc: $acc c: $c $typedChars ")
acc
}
c == '<' && acc.length <= 1 -> {twig("triggered: 2 $typedChars")
"0"
}
c == '<' -> {twig("triggered: 3")
acc.substring(0, acc.length - 1)
}
acc == "0" && c != '.' -> {twig("triggered: 4 $typedChars")
c.toString()
}
else -> {twig("triggered: 5 $typedChars")
"$acc$c"
}
}
}
uiModels = synchronizer.run {
combine(status, progress, balances, zec) { s, p, b, z->
UiModel(s, p, b.available, b.total, z)
}
}.conflate()
}
override fun onCleared() {
super.onCleared()
twig("HomeViewModel cleared!")
}
@Parcelize
data class UiModel( // <- THIS ERROR IS AN IDE BUG WITH PARCELIZE
val status: Synchronizer.Status = DISCONNECTED,
val progress: Int = 0,
val availableBalance: Long = -1L,
val totalBalance: Long = -1L,
val pendingSend: String = "0"
): Parcelable {
// Note: the wallet is effectively empty if it cannot cover the miner's fee
val hasFunds: Boolean get() = availableBalance > (MINERS_FEE_ZATOSHI.toDouble() / ZATOSHI_PER_ZEC) // 0.0001
val isSynced: Boolean get() = status == SYNCED
val isSendEnabled: Boolean get() = isSynced && hasFunds
}
}

View File

@ -4,6 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:background="@drawable/background_home">
<View
@ -27,14 +28,28 @@
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.04" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_send_amount_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.13" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_send_amount_bottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.23" />
<TextView
android:id="@+id/text_balance_available"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="12.34581242 ZEC"
android:text="Updating"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/text_light"
android:visibility="gone"
android:visibility="visible"
app:layout_constraintEnd_toStartOf="@id/label_balance"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
@ -53,6 +68,17 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/text_balance_available" />
<TextView
android:id="@+id/text_balance_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="(enter an amount to send)"
android:visibility="gone"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_balance_available" />
<!-- -->
<!-- Number Pad -->
@ -213,20 +239,23 @@
app:layout_constraintTop_toBottomOf="@id/button_number_pad_9"
app:layout_constraintWidth_percent="@dimen/calculator_button_width_percent" />
<!-- TODO: properly style this as a button with ripples -->
<TextView
<com.google.android.material.button.MaterialButton
android:id="@+id/button_send"
android:layout_width="0dp"
android:layout_height="46dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:background="@drawable/background_button_rounded"
android:gravity="center"
style="@style/Zcash.Button"
android:text="Send Amount"
android:enabled="false"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="#000000"
android:gravity="center"
android:padding="12dp"
app:layout_constraintEnd_toEndOf="@id/guide_keys"
app:layout_constraintStart_toStartOf="@id/guide_keys"
app:layout_constraintTop_toBottomOf="@id/guide_keys" />
app:layout_constraintTop_toBottomOf="@id/guide_keys"/>
<View
android:id="@+id/layer_lock"
@ -234,6 +263,7 @@
android:layout_height="match_parent"
android:clickable="true"
android:background="#D0000000"
tools:visibility="gone"
android:elevation="5dp" />
<!-- -->
@ -322,36 +352,25 @@
app:layout_constraintTop_toBottomOf="@id/button_send"
app:layout_constraintVertical_bias="@dimen/ratio_golden_small" />
<ImageView
android:id="@+id/icon_zec_symbol"
android:elevation="6dp"
android:layout_width="0dp"
android:layout_height="0dp"
android:tint="@color/colorAccent"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@id/text_send_amount"
app:layout_constraintEnd_toStartOf="@id/text_send_amount"
app:layout_constraintHeight_percent="0.052"
app:layout_constraintTop_toTopOf="@id/text_send_amount"
app:layout_constraintVertical_bias="0.2"
app:layout_constraintWidth_percent="0.060"
app:srcCompat="@drawable/ic_zec_symbol" />
<TextView
android:id="@+id/text_send_amount"
android:elevation="6dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:gravity="center"
android:includeFontPadding="false"
android:text="$0"
android:textAppearance="@style/Zcash.TextAppearance.Zec"
android:textSize="72dp"
app:layout_constraintBottom_toTopOf="@id/guide_keys"
android:maxLines="1"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:autoSizeTextType="uniform"
app:layout_constraintBottom_toTopOf="@id/guideline_send_amount_bottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toTopOf="@id/guideline_send_amount_top" />
<!-- -->
<!-- Banner -->
@ -379,7 +398,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="Learn More"
android:text="Fund Now"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/colorPrimary"
app:layout_constraintBaseline_toBaselineOf="@id/text_banner_message"
@ -395,6 +414,7 @@
android:id="@+id/group_banner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="text_banner_message, text_banner_action" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -24,6 +24,7 @@
<color name="zcashWhite">#F5F5F5</color>
<color name="zcashWhite_12">#1FFFFFFF</color>
<color name="zcashWhite_24">#3DFFFFFF</color>
<color name="zcashWhite_40">#66FFFFFF</color>
<color name="zcashWhite_50">#80FFFFFF</color>
<color name="zcashWhite_60">#A3FFFFFF</color>