445 lines
17 KiB
Kotlin
445 lines
17 KiB
Kotlin
package cash.z.ecc.android.ui.home
|
|
|
|
import android.content.Context
|
|
import android.os.Bundle
|
|
import android.view.LayoutInflater
|
|
import android.view.View
|
|
import android.widget.TextView
|
|
import androidx.lifecycle.lifecycleScope
|
|
import cash.z.ecc.android.R
|
|
import cash.z.ecc.android.databinding.FragmentHomeBinding
|
|
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
|
import cash.z.ecc.android.di.viewmodel.viewModel
|
|
import cash.z.ecc.android.ext.*
|
|
import cash.z.ecc.android.feedback.Report
|
|
import cash.z.ecc.android.feedback.Report.Tap.*
|
|
import cash.z.ecc.android.ui.base.BaseFragment
|
|
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
|
|
import cash.z.ecc.android.ui.send.SendViewModel
|
|
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
|
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED
|
|
import cash.z.wallet.sdk.Synchronizer
|
|
import cash.z.wallet.sdk.Synchronizer.Status.SYNCED
|
|
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 kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.*
|
|
import kotlinx.coroutines.isActive
|
|
import kotlinx.coroutines.launch
|
|
|
|
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|
override val screen = Report.Screen.HOME
|
|
|
|
private lateinit var numberPad: List<TextView>
|
|
private lateinit var uiModel: HomeViewModel.UiModel
|
|
|
|
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
|
private val sendViewModel: SendViewModel by activityViewModel()
|
|
private val viewModel: HomeViewModel by viewModel()
|
|
|
|
lateinit var snake: MagicSnakeLoader
|
|
|
|
override fun inflate(inflater: LayoutInflater): FragmentHomeBinding =
|
|
FragmentHomeBinding.inflate(inflater)
|
|
|
|
|
|
//
|
|
// LifeCycle
|
|
//
|
|
|
|
override fun onAttach(context: Context) {
|
|
twig("HomeFragment.onAttach")
|
|
twig("ZZZ")
|
|
twig("ZZZ")
|
|
twig("ZZZ")
|
|
twig("ZZZ ===================== HOME FRAGMENT CREATED ==================================")
|
|
super.onAttach(context)
|
|
|
|
// this will call startSync either now or later (after initializing with newly created seed)
|
|
walletSetup.checkSeed().onEach {
|
|
twig("Checking seed")
|
|
if (it == NO_SEED) {
|
|
// interact with user to create, backup and verify seed
|
|
// leads to a call to startSync(), later (after accounts are created from seed)
|
|
twig("Seed not found, therefore, launching seed creation flow")
|
|
mainActivity?.safeNavigate(R.id.action_nav_home_to_create_wallet)
|
|
} else {
|
|
twig("Found seed. Re-opening existing wallet")
|
|
mainActivity?.startSync(walletSetup.openWallet())
|
|
}
|
|
}.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_profile) { tapped(HOME_PROFILE) }
|
|
iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
|
|
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
|
|
hitAreaScan.setOnClickListener {
|
|
mainActivity?.maybeOpenScan().also { tapped(HOME_SCAN) }
|
|
}
|
|
|
|
textBannerAction.setOnClickListener {
|
|
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
|
|
}
|
|
buttonSendAmount.setOnClickListener {
|
|
onSend().also { tapped(HOME_SEND) }
|
|
}
|
|
setSendAmount("0", false)
|
|
|
|
snake = MagicSnakeLoader(binding.lottieButtonLoading)
|
|
}
|
|
|
|
binding.buttonNumberPadBack.setOnLongClickListener {
|
|
onClearAmount().also { tapped(HOME_CLEAR_AMOUNT) }
|
|
true
|
|
}
|
|
|
|
if (::uiModel.isInitialized) {
|
|
twig("uiModel exists!")
|
|
onModelUpdated(null, uiModel)
|
|
}
|
|
}
|
|
|
|
private fun onClearAmount() {
|
|
if (::uiModel.isInitialized) {
|
|
resumedScope.launch {
|
|
binding.textSendAmount.text.apply {
|
|
while (uiModel.pendingSend != "0") {
|
|
viewModel.onChar('<')
|
|
delay(5)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
twig("HomeFragment.onResume resumeScope.isActive: ${resumedScope.isActive} $resumedScope")
|
|
viewModel.initializeMaybe()
|
|
onClearAmount()
|
|
viewModel.uiModels.scanReduce { old, new ->
|
|
onModelUpdated(old, new)
|
|
new
|
|
}.onCompletion {
|
|
twig("uiModel.scanReduce completed.")
|
|
}.catch { e ->
|
|
twig("exception while processing uiModels $e")
|
|
throw 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 {
|
|
viewModel.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
|
|
//
|
|
|
|
var isSendEnabled = false
|
|
fun setSendEnabled(enabled: Boolean, isSynced: Boolean) {
|
|
isSendEnabled = enabled
|
|
binding.buttonSendAmount.apply {
|
|
if (enabled || !isSynced) {
|
|
isEnabled = true
|
|
isClickable = isSynced
|
|
binding.lottieButtonLoading.alpha = 1.0f
|
|
} else {
|
|
isEnabled = false
|
|
isClickable = false
|
|
binding.lottieButtonLoading.alpha = 0.32f
|
|
}
|
|
}
|
|
}
|
|
|
|
fun setProgress(uiModel: HomeViewModel.UiModel) {
|
|
if (!uiModel.processorInfo.hasData) {
|
|
twig("Warning: ignoring progress update because the processor has not started.")
|
|
return
|
|
}
|
|
|
|
snake.isSynced = uiModel.isSynced
|
|
if (!uiModel.isSynced) {
|
|
snake.downloadProgress = uiModel.downloadProgress
|
|
snake.scanProgress = uiModel.scanProgress
|
|
}
|
|
|
|
val sendText = when {
|
|
uiModel.isSynced -> if (uiModel.hasFunds) "SEND AMOUNT" else "NO FUNDS AVAILABLE"
|
|
uiModel.status == Synchronizer.Status.DISCONNECTED -> "DISCONNECTED"
|
|
uiModel.status == Synchronizer.Status.STOPPED -> "IDLE"
|
|
uiModel.isDownloading -> "Downloading . . . ${snake.downloadProgress}%"
|
|
uiModel.isValidating -> "Validating . . ."
|
|
uiModel.isScanning -> "Scanning . . . ${snake.scanProgress}%"
|
|
else -> "Updating"
|
|
}
|
|
|
|
// binding.lottieButtonLoading.progress = if (uiModel.isSynced) 1.0f else uiModel.totalProgress * 0.82f // line fully closes at 82% mark
|
|
binding.buttonSendAmount.text = sendText
|
|
// twig("Lottie progress set to ${binding.lottieButtonLoading.progress} (isSynced? ${uiModel.isSynced})")
|
|
twig("Send button set to: $sendText")
|
|
|
|
val resId = if (uiModel.isSynced) R.color.selector_button_text_dark else R.color.selector_button_text_light
|
|
binding.buttonSendAmount.setTextColor(resources.getColorStateList(resId))
|
|
|
|
// if (uiModel.status == DISCONNECTED || uiModel.status == STOPPED) {
|
|
// binding.buttonSendAmount.backgroundTintList = ColorStateList.valueOf(resources.getColor(R.color.zcashGray))
|
|
// } else {
|
|
// binding.buttonSendAmount.backgroundTintList = null
|
|
// }
|
|
}
|
|
|
|
/**
|
|
* @param amount the amount to send represented as ZEC, without the dollar sign.
|
|
*/
|
|
fun setSendAmount(amount: String, updateModel: Boolean = true) {
|
|
binding.textSendAmount.text = "\$$amount".toColoredSpan(R.color.text_light_dimmed, "$")
|
|
if (updateModel) {
|
|
sendViewModel.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi()
|
|
}
|
|
binding.buttonSendAmount.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)) {
|
|
val change = (totalBalance - availableBalance).convertZatoshiToZecString()
|
|
"(expecting +$change ZEC)".toColoredSpan(R.color.text_light, "+$change")
|
|
} else {
|
|
"(enter an amount to send)"
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
logUpdate(old, new)
|
|
if (binding.lottieButtonLoading.visibility != View.VISIBLE) binding.lottieButtonLoading.visibility = View.VISIBLE
|
|
uiModel = new
|
|
if (old?.pendingSend != new.pendingSend) {
|
|
setSendAmount(new.pendingSend)
|
|
}
|
|
// TODO: handle stopped and disconnected flows
|
|
setProgress(uiModel) // TODO: we may not need to separate anymore
|
|
// if (new.status = SYNCING) onSyncing(new) else onSynced(new)
|
|
if (new.status == SYNCED) onSynced(new) else onSyncing(new)
|
|
setSendEnabled(new.isSendEnabled, new.status == SYNCED)
|
|
}
|
|
|
|
private fun logUpdate(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
|
|
var message = ""
|
|
fun maybeComma() = if (message.length > "UiModel(".length) ", " else ""
|
|
message = when {
|
|
old == null -> "$new"
|
|
new == null -> "null"
|
|
else -> {
|
|
buildString {
|
|
append("UiModel(")
|
|
if (old.status != new.status) append ("status=${new.status}")
|
|
if (old.processorInfo != new.processorInfo) {
|
|
append ("${maybeComma()}processorInfo=ProcessorInfo(")
|
|
val startLength = length
|
|
fun innerComma() = if (length > startLength) ", " else ""
|
|
if (old.processorInfo.networkBlockHeight != new.processorInfo.networkBlockHeight) append("networkBlockHeight=${new.processorInfo.networkBlockHeight}")
|
|
if (old.processorInfo.lastScannedHeight != new.processorInfo.lastScannedHeight) append("${innerComma()}lastScannedHeight=${new.processorInfo.lastScannedHeight}")
|
|
if (old.processorInfo.lastDownloadedHeight != new.processorInfo.lastDownloadedHeight) append("${innerComma()}lastDownloadedHeight=${new.processorInfo.lastDownloadedHeight}")
|
|
if (old.processorInfo.lastDownloadRange != new.processorInfo.lastDownloadRange) append("${innerComma()}lastDownloadRange=${new.processorInfo.lastDownloadRange}")
|
|
if (old.processorInfo.lastScanRange != new.processorInfo.lastScanRange) append("${innerComma()}lastScanRange=${new.processorInfo.lastScanRange}")
|
|
append(")")
|
|
}
|
|
if (old.availableBalance != new.availableBalance) append ("${maybeComma()}availableBalance=${new.availableBalance}")
|
|
if (old.totalBalance != new.totalBalance) append ("${maybeComma()}totalBalance=${new.totalBalance}")
|
|
if (old.pendingSend != new.pendingSend) append ("${maybeComma()}pendingSend=${new.pendingSend}")
|
|
append(")")
|
|
}
|
|
}
|
|
}
|
|
twig("onModelUpdated: $message")
|
|
}
|
|
|
|
private fun onSyncing(uiModel: HomeViewModel.UiModel) {
|
|
setAvailable()
|
|
}
|
|
|
|
private fun onSynced(uiModel: HomeViewModel.UiModel) {
|
|
snake.isSynced = true
|
|
if (!uiModel.hasBalance) {
|
|
onNoFunds()
|
|
} else {
|
|
setBanner("")
|
|
setAvailable(uiModel.availableBalance, uiModel.totalBalance)
|
|
}
|
|
}
|
|
|
|
private fun onSend() {
|
|
if (isSendEnabled) mainActivity?.safeNavigate(R.id.action_nav_home_to_send)
|
|
}
|
|
|
|
private fun onBannerAction(action: BannerAction) {
|
|
when (action) {
|
|
FUND_NOW -> {
|
|
MaterialAlertDialogBuilder(activity)
|
|
.setMessage("To make full use of this wallet, deposit funds to your address.")
|
|
.setTitle("No Balance")
|
|
.setCancelable(true)
|
|
.setPositiveButton("View Address") { dialog, _ ->
|
|
tapped(HOME_FUND_NOW)
|
|
dialog.dismiss()
|
|
mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
|
}
|
|
.show()
|
|
// 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")
|
|
// .setCancelable(true)
|
|
// .setPositiveButton("Tap Faucet") { dialog, _ ->
|
|
// dialog.dismiss()
|
|
// setBanner("Tapping faucet...", CANCEL)
|
|
// }
|
|
// .setNegativeButton("View Address") { dialog, _ ->
|
|
// dialog.dismiss()
|
|
// mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
|
// }
|
|
// .show()
|
|
}
|
|
CANCEL -> {
|
|
// TODO: trigger banner / balance update
|
|
onNoFunds()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun onNoFunds() {
|
|
setBanner("No Balance", FUND_NOW)
|
|
}
|
|
|
|
|
|
//
|
|
// Inner classes and extensions
|
|
//
|
|
|
|
enum class BannerAction(val action: String) {
|
|
FUND_NOW(""),
|
|
CANCEL("Cancel"),
|
|
NONE(""),
|
|
CLEAR("clear");
|
|
|
|
companion object {
|
|
fun from(action: String?): BannerAction {
|
|
values().forEach {
|
|
if (it.action == action) return it
|
|
}
|
|
throw IllegalArgumentException("Invalid BannerAction: $action")
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun TextView.asKey(): TextView {
|
|
val c = text[0]
|
|
setOnClickListener {
|
|
lifecycleScope.launch {
|
|
twig("CHAR TYPED: $c")
|
|
viewModel.onChar(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")
|
|
}
|
|
} |