[#12] Home screen scaffold (#158)

This provides a very basic scaffold of the home screen and navigation to other child screens.  Additional work needs to be done in both the SDK and this app to build all of the functionality.  Specific known gaps
 - Displaying synchronization status to the user, pending full designs on the progress and animation behaviors
 - Improved APIs for listing and filtering transactions.  This is the issue in the SDK https://github.com/zcash/zcash-android-wallet-sdk/issues/242
 - Implement the UI for displaying transaction history
 - Display fiat currency values
 - Hook up buttons to navigate to other screens (scan #137, profile #145, send #134, request #135)
This commit is contained in:
Carter Jernigan 2021-12-29 14:38:14 -05:00 committed by GitHub
parent dcea0ec842
commit 205344c201
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 457 additions and 61 deletions

View File

@ -69,26 +69,26 @@ PLAY_PUBLISHER_PLUGIN_VERSION_MATCHER=3.7.0
ANDROIDX_ACTIVITY_VERSION=1.4.0
ANDROIDX_ANNOTATION_VERSION=1.3.0
ANDROIDX_APPCOMPAT_VERSION=1.3.1
ANDROIDX_COMPOSE_VERSION=1.0.5
ANDROIDX_COMPOSE_COMPILER_VERSION=1.1.0-rc02
ANDROIDX_COMPOSE_VERSION=1.0.5
ANDROIDX_CORE_VERSION=1.7.0
ANDROIDX_ESPRESSO_VERSION=3.4.0
ANDROIDX_LIFECYCLE_VERSION=2.4.0
ANDROIDX_NAVIGATION_VERSION=2.3.5
ANDROIDX_NAVIGATION_COMPOSE_VERSION=2.4.0-rc01
ANDROIDX_SECURITY_CRYPTO_VERSION=1.1.0-alpha03
ANDROIDX_SPLASH_SCREEN_VERSION=1.0.0-alpha02
ANDROIDX_TEST_VERSION=1.4.1-alpha03
ANDROIDX_TEST_JUNIT_VERSION=1.1.3
ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.1
ANDROIDX_TEST_VERSION=1.4.1-alpha03
ANDROIDX_UI_AUTOMATOR_VERSION=2.2.0-alpha1
CORE_LIBRARY_DESUGARING_VERSION=1.1.5
GOOGLE_MATERIAL_VERSION=1.4.0
JACOCO_VERSION=0.8.7
KOTLINX_COROUTINES_VERSION=1.6.0
KOTLIN_VERSION=1.6.10
ZCASH_SDK_VERSION=1.3.0-beta19
ZCASH_BIP39_VERSION=1.0.2
KOTLINX_COROUTINES_VERSION=1.6.0
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
ZCASH_BIP39_VERSION=1.0.2
ZCASH_SDK_VERSION=1.3.0-beta19
# Toolchain is the Java version used to build the application, which is separate from the
# Java version used to run the application. Android requires a minimum of 11.

View File

@ -1,4 +1,4 @@
package cash.z.ecc.ui.screen.onboarding.model
package cash.z.ecc.sdk.model
import androidx.test.filters.SmallTest
import org.junit.Test

View File

@ -0,0 +1,42 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
fun CompactBlockProcessor.ProcessorInfo.downloadProgress() = if (lastDownloadRange.isEmpty()) {
PercentDecimal.ONE_HUNDRED_PERCENT
} else {
val numerator = (lastDownloadedHeight - lastDownloadRange.first + 1)
.toFloat()
.coerceAtLeast(PercentDecimal.MIN)
val denominator = (lastDownloadRange.last - lastDownloadRange.first + 1).toFloat()
val progress = (numerator / denominator).coerceAtMost(PercentDecimal.MAX)
PercentDecimal(progress)
}
fun CompactBlockProcessor.ProcessorInfo.scanProgress() = if (lastScanRange.isEmpty()) {
PercentDecimal.ONE_HUNDRED_PERCENT
} else {
val numerator = (lastScannedHeight - lastScanRange.first + 1).toFloat().coerceAtLeast(PercentDecimal.MIN)
val demonimator = (lastScanRange.last - lastScanRange.first + 1).toFloat()
val progress = (numerator / demonimator).coerceAtMost(PercentDecimal.MAX)
PercentDecimal(progress)
}
// These are estimates
@Suppress("MagicNumber")
private val DOWNLOAD_WEIGHT = PercentDecimal(0.4f)
private val SCAN_WEIGHT = PercentDecimal(PercentDecimal.MAX - DOWNLOAD_WEIGHT.decimal)
fun CompactBlockProcessor.ProcessorInfo.totalProgress(): PercentDecimal {
val downloadWeighted = DOWNLOAD_WEIGHT.decimal * (downloadProgress().decimal).coerceAtMost(PercentDecimal.MAX)
val scanWeighted = SCAN_WEIGHT.decimal * (scanProgress().decimal).coerceAtMost(PercentDecimal.MAX)
return PercentDecimal(
downloadWeighted.coerceAtLeast(PercentDecimal.MIN) +
scanWeighted.coerceAtLeast(PercentDecimal.MIN)
)
}

View File

@ -0,0 +1,25 @@
package cash.z.ecc.sdk.model
/**
* @param decimal A percent represented as a `Double` decimal value in the range of [0, 1].
*/
@JvmInline
value class PercentDecimal(val decimal: Float) {
init {
require(decimal >= MIN)
require(decimal <= MAX)
}
companion object {
const val MIN = 0.0f
const val MAX = 1.0f
val ZERO_PERCENT = PercentDecimal(MIN)
val ONE_HUNDRED_PERCENT = PercentDecimal(MAX)
fun newLenient(decimal: Float) = PercentDecimal(
decimal
.coerceAtLeast(MIN)
.coerceAtMost(MAX)
)
}
}

View File

@ -0,0 +1,8 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.android.sdk.type.WalletBalance
// These go away if we update WalletBalance to expose a Zatoshi field type instead of long
val WalletBalance.total get() = Zatoshi(totalZatoshi.coerceAtLeast(0))
val WalletBalance.available get() = Zatoshi(availableZatoshi.coerceAtLeast(0))
val WalletBalance.pending get() = Zatoshi(pendingZatoshi.coerceAtLeast(0))

View File

@ -0,0 +1,17 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
// Eventually, this could move into the SDK and provide a stronger API for amounts
@JvmInline
value class Zatoshi(val amount: Long) {
init {
require(amount >= 0)
}
override fun toString() = amount.convertZatoshiToZecString(DECIMALS, DECIMALS)
companion object {
private const val DECIMALS = 8
}
}

View File

@ -56,6 +56,7 @@ dependencyResolutionManagement {
val androidxCoreVersion = extra["ANDROIDX_CORE_VERSION"].toString()
val androidxEspressoVersion = extra["ANDROIDX_ESPRESSO_VERSION"].toString()
val androidxLifecycleVersion = extra["ANDROIDX_LIFECYCLE_VERSION"].toString()
val androidxNavigationComposeVersion = extra["ANDROIDX_NAVIGATION_COMPOSE_VERSION"].toString()
val androidxSecurityCryptoVersion = extra["ANDROIDX_SECURITY_CRYPTO_VERSION"].toString()
val androidxSplashScreenVersion = extra["ANDROIDX_SPLASH_SCREEN_VERSION"].toString()
val androidxTestJunitVersion = extra["ANDROIDX_TEST_JUNIT_VERSION"].toString()
@ -89,6 +90,7 @@ dependencyResolutionManagement {
alias("androidx-compose-compiler").to("androidx.compose.compiler:compiler:$androidxComposeCompilerVersion")
alias("androidx-core").to("androidx.core:core-ktx:$androidxCoreVersion")
alias("androidx-lifecycle-livedata").to("androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion")
alias("androidx-navigation-compose").to("androidx.navigation:navigation-compose:$androidxNavigationComposeVersion")
alias("androidx-security-crypto").to("androidx.security:security-crypto-ktx:$androidxSecurityCryptoVersion")
alias("androidx-splash").to("androidx.core:core-splashscreen:$androidxSplashScreenVersion")
alias("androidx-viewmodel-compose").to("androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
@ -127,6 +129,7 @@ dependencyResolutionManagement {
"androidx-compose-material-icons-extended",
"androidx-compose-tooling",
"androidx-compose-ui",
"androidx-navigation-compose",
"androidx-viewmodel-compose"
)
)

View File

@ -27,9 +27,10 @@ android {
getByName("main").apply {
res.setSrcDirs(
setOf(
"src/main/res/ui/common",
"src/main/res/ui/onboarding",
"src/main/res/ui/backup",
"src/main/res/ui/common",
"src/main/res/ui/home",
"src/main/res/ui/onboarding",
"src/main/res/ui/restore"
)
)

View File

@ -8,6 +8,7 @@ import android.os.SystemClock
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.FontRes
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@ -17,6 +18,9 @@ import androidx.compose.ui.Modifier
import androidx.core.content.res.ResourcesCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import cash.z.ecc.android.sdk.type.WalletBirthday
import cash.z.ecc.android.sdk.type.ZcashNetwork
import cash.z.ecc.sdk.model.PersistableWallet
@ -26,7 +30,7 @@ import cash.z.ecc.ui.screen.backup.view.BackupWallet
import cash.z.ecc.ui.screen.backup.viewmodel.BackupViewModel
import cash.z.ecc.ui.screen.common.GradientSurface
import cash.z.ecc.ui.screen.home.view.Home
import cash.z.ecc.ui.screen.home.viewmodel.WalletState
import cash.z.ecc.ui.screen.home.viewmodel.SecretState
import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel
import cash.z.ecc.ui.screen.onboarding.view.Onboarding
import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel
@ -77,7 +81,7 @@ class MainActivity : ComponentActivity() {
}
}
WalletState.Loading == walletViewModel.state.value
SecretState.Loading == walletViewModel.secretState.value
}
}
@ -89,21 +93,21 @@ class MainActivity : ComponentActivity() {
.fillMaxWidth()
.fillMaxHeight()
) {
val walletState = walletViewModel.state.collectAsState().value
val secretState = walletViewModel.secretState.collectAsState().value
when (walletState) {
WalletState.Loading -> {
when (secretState) {
SecretState.Loading -> {
// For now, keep displaying splash screen using condition above.
// In the future, we might consider displaying something different here.
}
WalletState.NoWallet -> {
SecretState.None -> {
WrapOnboarding()
}
is WalletState.NeedsBackup -> WrapBackup(walletState.persistableWallet)
is WalletState.Ready -> WrapHome(walletState.persistableWallet)
is SecretState.NeedsBackup -> WrapBackup(secretState.persistableWallet)
is SecretState.Ready -> Navigation()
}
if (walletState != WalletState.Loading) {
if (secretState != SecretState.Loading) {
reportFullyDrawn()
}
}
@ -191,8 +195,34 @@ class MainActivity : ComponentActivity() {
}
@Composable
private fun WrapHome(persistableWallet: PersistableWallet) {
Home(persistableWallet)
private fun Navigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") { WrapHome({}, {}, {}, {}) }
}
}
@Composable
private fun WrapHome(
goScan: () -> Unit,
goProfile: () -> Unit,
goSend: () -> Unit,
goRequest: () -> Unit
) {
val walletSnapshot = walletViewModel.walletSnapshot.collectAsState().value
if (null == walletSnapshot) {
// Display loading indicator
} else {
Home(
walletSnapshot,
walletViewModel.transactionSnapshot.collectAsState().value,
goScan = goScan,
goRequest = goRequest,
goSend = goSend,
goProfile = goProfile
)
}
}
companion object {
@ -210,7 +240,7 @@ class MainActivity : ComponentActivity() {
* caches the results. This moves that IO off the main thread, to prevent ANRs and
* jank during app startup.
*/
private suspend fun prefetchFontLegacy(context: Context, @androidx.annotation.FontRes fontRes: Int) =
private suspend fun prefetchFontLegacy(context: Context, @FontRes fontRes: Int) =
withContext(Dispatchers.IO) {
ResourcesCompat.getFont(context, fontRes)
}

View File

@ -0,0 +1,20 @@
package cash.z.ecc.ui.fixture
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.ui.screen.home.model.WalletSnapshot
@Suppress("MagicNumber")
object WalletSnapshotFixture {
// Should fill in with non-empty values for better example values in tests and UI previews
@Suppress("LongParameterList")
fun new(
status: Synchronizer.Status = Synchronizer.Status.SYNCED,
processorInfo: CompactBlockProcessor.ProcessorInfo = CompactBlockProcessor.ProcessorInfo(),
orchardBalance: WalletBalance = WalletBalance(5, 2),
saplingBalance: WalletBalance = WalletBalance(4, 4),
transparentBalance: WalletBalance = WalletBalance(8, 1),
pendingCount: Int = 0
) = WalletSnapshot(status, processorInfo, orchardBalance, saplingBalance, transparentBalance, pendingCount)
}

View File

@ -0,0 +1,29 @@
package cash.z.ecc.ui.screen.home.model
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.sdk.model.Zatoshi
data class WalletSnapshot(
val status: Synchronizer.Status,
val processorInfo: CompactBlockProcessor.ProcessorInfo,
val orchardBalance: WalletBalance,
val saplingBalance: WalletBalance,
val transparentBalance: WalletBalance,
val pendingCount: Int
) {
// Note: the wallet is effectively empty if it cannot cover the miner's fee
val hasFunds = saplingBalance.availableZatoshi >
(ZcashSdk.MINERS_FEE_ZATOSHI.toDouble() / ZcashSdk.ZATOSHI_PER_ZEC) // 0.00001
val hasSaplingBalance = saplingBalance.totalZatoshi > 0
val isSendEnabled: Boolean get() = status == Synchronizer.Status.SYNCED && hasFunds
}
fun WalletSnapshot.totalBalance(): Zatoshi {
val total = (orchardBalance + saplingBalance + transparentBalance).totalZatoshi
return Zatoshi(total.coerceAtLeast(0))
}

View File

@ -1,12 +1,36 @@
package cash.z.ecc.ui.screen.home.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
import cash.z.ecc.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.db.entity.Transaction
import cash.z.ecc.sdk.model.total
import cash.z.ecc.ui.R
import cash.z.ecc.ui.fixture.WalletSnapshotFixture
import cash.z.ecc.ui.screen.common.Body
import cash.z.ecc.ui.screen.common.GradientSurface
import cash.z.ecc.ui.screen.common.Header
import cash.z.ecc.ui.screen.common.PrimaryButton
import cash.z.ecc.ui.screen.common.TertiaryButton
import cash.z.ecc.ui.screen.home.model.WalletSnapshot
import cash.z.ecc.ui.screen.home.model.totalBalance
import cash.z.ecc.ui.theme.MINIMAL_WEIGHT
import cash.z.ecc.ui.theme.ZcashTheme
@Preview
@ -14,15 +38,111 @@ import cash.z.ecc.ui.theme.ZcashTheme
fun ComposablePreview() {
ZcashTheme(darkTheme = true) {
GradientSurface {
Home(PersistableWalletFixture.new())
Home(
WalletSnapshotFixture.new(),
emptyList(),
goScan = {},
goProfile = {},
goSend = {},
goRequest = {}
)
}
}
}
@Suppress("LongParameterList")
@Composable
fun Home(@Suppress("UNUSED_PARAMETER") persistableWallet: PersistableWallet) {
Column {
// Placeholder
Text("Welcome to your wallet")
fun Home(
walletSnapshot: WalletSnapshot,
transactionHistory: List<Transaction>,
goScan: () -> Unit,
goProfile: () -> Unit,
goSend: () -> Unit,
goRequest: () -> Unit
) {
Scaffold(topBar = {
HomeTopAppBar()
}) {
HomeMainContent(
walletSnapshot,
transactionHistory,
goScan = goScan,
goProfile = goProfile,
goSend = goSend,
goRequest = goRequest
)
}
}
@Composable
private fun HomeTopAppBar() {
TopAppBar {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.app_name))
}
}
}
@Suppress("LongParameterList")
@Composable
private fun HomeMainContent(
walletSnapshot: WalletSnapshot,
transactionHistory: List<Transaction>,
goScan: () -> Unit,
goProfile: () -> Unit,
goSend: () -> Unit,
goRequest: () -> Unit
) {
Column {
Row(Modifier.fillMaxWidth()) {
IconButton(goScan) {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = stringResource(R.string.home_scan_content_description)
)
}
Spacer(
Modifier
.fillMaxWidth()
.weight(MINIMAL_WEIGHT)
)
IconButton(goProfile) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = stringResource(R.string.home_profile_content_description)
)
}
}
Status(walletSnapshot)
PrimaryButton(onClick = goSend, text = stringResource(R.string.home_button_send))
TertiaryButton(onClick = goRequest, text = stringResource(R.string.home_button_request))
History(transactionHistory)
}
}
@Composable
private fun Status(@Suppress("UNUSED_PARAMETER") walletSnapshot: WalletSnapshot) {
Column(Modifier.fillMaxWidth()) {
Header(text = walletSnapshot.totalBalance().toString())
Body(
text = stringResource(
id = R.string.home_status_shielding_format,
walletSnapshot.saplingBalance.total.toString()
)
)
}
}
@Composable
private fun History(transactionHistory: List<Transaction>) {
Column(Modifier.fillMaxWidth()) {
LazyColumn {
items(transactionHistory) {
Text(it.toString())
}
}
}
}

View File

@ -4,6 +4,12 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
import cash.z.ecc.android.sdk.db.entity.Transaction
import cash.z.ecc.android.sdk.db.entity.isMined
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.sdk.SynchronizerCompanion
import cash.z.ecc.sdk.model.PersistableWallet
import cash.z.ecc.ui.common.ANDROID_STATE_FLOW_TIMEOUT_MILLIS
@ -11,10 +17,19 @@ import cash.z.ecc.ui.preference.EncryptedPreferenceKeys
import cash.z.ecc.ui.preference.EncryptedPreferenceSingleton
import cash.z.ecc.ui.preference.StandardPreferenceKeys
import cash.z.ecc.ui.preference.StandardPreferenceSingleton
import cash.z.ecc.ui.screen.home.model.WalletSnapshot
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@ -33,7 +48,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
/**
* A flow of the user's stored wallet. Null indicates that no wallet has been stored.
*/
private val persistableWalletFlow = flow {
private val persistableWallet = flow {
// EncryptedPreferenceSingleton.getInstance() is a suspending function, which is why we need
// the flow builder to provide a coroutine context.
val encryptedPreferenceProvider = EncryptedPreferenceSingleton.getInstance(application)
@ -44,29 +59,70 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
/**
* A flow of whether a backup of the user's wallet has been performed.
*/
private val isBackupCompleteFlow = flow {
private val isBackupComplete = flow {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
emitAll(StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.observe(preferenceProvider))
}
val state: StateFlow<WalletState> = persistableWalletFlow
.combine(isBackupCompleteFlow) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean ->
val secretState: StateFlow<SecretState> = persistableWallet
.combine(isBackupComplete) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean ->
if (null == persistableWallet) {
WalletState.NoWallet
SecretState.None
} else if (!isBackupComplete) {
WalletState.NeedsBackup(persistableWallet)
SecretState.NeedsBackup(persistableWallet)
} else {
WalletState.Ready(persistableWallet, SynchronizerCompanion.load(application, persistableWallet))
SecretState.Ready(persistableWallet)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
WalletState.Loading
SecretState.Loading
)
// This will likely move to an application global, so that it can be referenced by WorkManager
// for background synchronization
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val synchronizer: StateFlow<Synchronizer?> = secretState
.filterIsInstance<SecretState.Ready>()
.flatMapConcat {
callbackFlow {
val synchronizer = SynchronizerCompanion.load(application, it.persistableWallet)
synchronizer.start(viewModelScope)
trySend(synchronizer)
awaitClose {
synchronizer.stop()
}
}
}.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
null
)
@OptIn(FlowPreview::class)
val walletSnapshot: StateFlow<WalletSnapshot?> = synchronizer
.filterNotNull()
.flatMapConcat { it.toWalletSnapshot() }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
null
)
// This is not the right API, because the transaction list could be very long and might need UI filtering
@OptIn(FlowPreview::class)
val transactionSnapshot: StateFlow<List<Transaction>> = synchronizer
.filterNotNull()
.flatMapConcat { it.toTransactions() }
.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
emptyList()
)
/**
* Creates a wallet asynchronously and then persists it. Clients observe
* [state] to see the side effects. This would be used for a user creating a new wallet.
* [secretState] to see the side effects. This would be used for a user creating a new wallet.
*/
/*
* Although waiting for the wallet to be written and then read back is slower, it is probably
@ -82,7 +138,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
}
/**
* Persists a wallet asynchronously. Clients observe [state]
* Persists a wallet asynchronously. Clients observe [secretState]
* to see the side effects. This would be used for a user restoring a wallet from a backup.
*/
fun persistExistingWallet(persistableWallet: PersistableWallet) {
@ -98,7 +154,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
/**
* Asynchronously notes that the user has completed the backup steps, which means the wallet
* is ready to use. Clients observe [state] to see the side effects. This would be used
* is ready to use. Clients observe [secretState] to see the side effects. This would be used
* for a user creating a new wallet.
*/
fun persistBackupComplete() {
@ -119,11 +175,53 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
}
/**
* Represents the state of the wallet
* Represents the state of the wallet secret.
*/
sealed class WalletState {
object Loading : WalletState()
object NoWallet : WalletState()
class NeedsBackup(val persistableWallet: PersistableWallet) : WalletState()
class Ready(val persistableWallet: PersistableWallet, val synchronizer: Synchronizer) : WalletState()
sealed class SecretState {
object Loading : SecretState()
object None : SecretState()
class NeedsBackup(val persistableWallet: PersistableWallet) : SecretState()
class Ready(val persistableWallet: PersistableWallet) : SecretState()
}
// No good way around needing magic numbers for the indices
@Suppress("MagicNumber")
private fun Synchronizer.toWalletSnapshot() =
combine(
status, // 0
processorInfo, // 1
orchardBalances, // 2
saplingBalances, // 3
transparentBalances, // 4
pendingTransactions.distinctUntilChanged() // 5
) { flows ->
val pendingCount = (flows[5] as List<*>)
.filterIsInstance(PendingTransaction::class.java)
.count {
it.isSubmitSuccess() && !it.isMined()
}
WalletSnapshot(
status = flows[0] as Synchronizer.Status,
processorInfo = flows[1] as CompactBlockProcessor.ProcessorInfo,
orchardBalance = flows[2] as WalletBalance,
saplingBalance = flows[3] as WalletBalance,
transparentBalance = flows[4] as WalletBalance,
pendingCount = pendingCount
)
}
private fun Synchronizer.toTransactions() =
combine(
clearedTransactions.distinctUntilChanged(),
pendingTransactions.distinctUntilChanged(),
sentTransactions.distinctUntilChanged(),
receivedTransactions.distinctUntilChanged(),
) { cleared, pending, sent, received ->
// TODO [#157]: Sort the transactions to show the most recent
buildList<Transaction> {
addAll(cleared)
addAll(pending)
addAll(sent)
addAll(received)
}
}

View File

@ -1,15 +0,0 @@
package cash.z.ecc.ui.screen.onboarding.model
/**
* @param decimal A percent represented as a `Double` decimal value in the range of [0, 1].
*/
@JvmInline
value class PercentDecimal(val decimal: Float) {
init {
require(EXPECTED_RANGE.contains(decimal)) { "$decimal is outside of range $EXPECTED_RANGE" }
}
companion object {
private val EXPECTED_RANGE = 0.0f..1.0f
}
}

View File

@ -1,12 +1,14 @@
package cash.z.ecc.ui.screen.onboarding.model
import cash.z.ecc.sdk.model.PercentDecimal
data class Progress(val current: Index, val last: Index) {
init {
require(last.value > 0) { "last must be > 0 but was $last" }
require(last.value >= current.value) { "last ($last) must be >= current ($current)" }
}
fun percent() = PercentDecimal((current.value + 1).toFloat() / (last.value + 1).toFloat())
fun percent() = PercentDecimal.newLenient((current.value + 1.toFloat()) / (last.value + 1).toFloat())
companion object {
val EMPTY = Progress(Index(0), Index(1))

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="59dp"
android:height="105dp"
android:viewportWidth="59"
android:viewportHeight="105">
<path
android:fillColor="#FF000000"
android:pathData="M59,26.586V13.911H36.429V0H22.567V13.911H0V30.7H34.987L6.375,70.258 0,78.4V91.074H22.567v13.867H24.23V105H34.767v-0.059h1.663V91.074H59V74.284H24.01L52.621,34.727 59,26.586Z"/>
</vector>

View File

@ -0,0 +1,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="home_scan_content_description">Scan</string>
<string name="home_profile_content_description">Profile</string>
<string name="home_button_send">Send</string>
<string name="home_status_shielding_format" formatted="true">Shielding <xliff:g id="shielded_amount" example=".023">%1$s</xliff:g></string>
<string name="home_button_request">Request ZEC</string>
</resources>