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:
parent
dcea0ec842
commit
205344c201
|
@ -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.
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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))
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue