[#1449] Display Synchronizer details in dialog

- Closes #1449
- Synchronizer status details are now available to users by pressing the simple status view. The details are displayed within the predefined Zashi dialog on the Balances and Account screens
- As this view also presents information about Zashi app updates available on Google Play, by pressing the view, the app redirects users to Google Play Zashi’s page
- As agreed, we’re moving towards more rounded corners in dialogs, which is part of these changes, too
- Added also several minor Balances screen UI improvements
- Improved biometric flow without any authentication method set on older Android versions
- Changelog update
This commit is contained in:
Honza Rychnovský 2024-05-24 10:41:57 +02:00 committed by GitHub
parent 00db536674
commit 7e9e89725b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 368 additions and 133 deletions

View File

@ -14,6 +14,14 @@ directly impact users rather than highlighting other key architectural updates.*
cases: Send funds, Recovery Phrase, Export Private Data, and Delete Wallet.
- The app entry animation has been reworked to apply on every app access point, i.e. it will be displayed when
users return to an already set up app as well.
- Synchronizer status details are now available to users by pressing the simple status view placed above the
synchronization progress bar. The details are displayed within a dialog window on the Balances and Account screens.
This view also occasionally presents information about a possible Zashi app update available on Google Play. The
app redirects users to the Google Play Zashi page by pressing the view.
### Changed
- The app dialog window has now a bit more rounded corners
- A few more minor UI improvements
## [1.0 (650)] - 2024-05-07

View File

@ -1,12 +1,13 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -61,7 +62,7 @@ fun AppAlertDialog(
properties: DialogProperties = DialogProperties()
) {
AlertDialog(
shape = RectangleShape,
shape = RoundedCornerShape(corner = CornerSize(ZcashTheme.dimens.regularRippleEffectCorner)),
onDismissRequest = onDismissRequest?.let { onDismissRequest } ?: {},
confirmButton = {
confirmButtonText?.let {

View File

@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp
data class Dimens(
// Default spacings:
val spacingNone: Dp,
val spacingMini: Dp,
val spacingXtiny: Dp,
val spacingMin: Dp,
val spacingTiny: Dp,
@ -61,6 +62,7 @@ data class Dimens(
private val defaultDimens =
Dimens(
spacingNone = 0.dp,
spacingMini = 1.dp,
spacingXtiny = 2.dp,
spacingTiny = 4.dp,
spacingMin = 6.dp,

View File

@ -1,5 +1,6 @@
package co.electriccoin.zcash.ui.screen.account
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
@ -70,6 +71,10 @@ class AccountTestSetup(
onTransactionItemAction = {
onItemClickCount.incrementAndGet()
},
hideStatusDialog = {},
showStatusDialog = null,
onStatusClick = {},
snackbarHostState = SnackbarHostState(),
walletRestoringState = WalletRestoringState.NONE,
walletSnapshot = WalletSnapshotFixture.new()
)

View File

@ -30,6 +30,7 @@ class HistoryTestSetup(
ZcashTheme {
HistoryContainer(
transactionState = initialHistoryUiState,
onStatusClick = {},
onTransactionItemAction = {
onItemIdClickCount.incrementAndGet()
},

View File

@ -1,5 +1,6 @@
package co.electriccoin.zcash.ui.screen.balances
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
@ -35,7 +36,10 @@ class BalancesTestSetup(
onSettings = {
onSettingsCount.incrementAndGet()
},
isDetailedStatus = false,
hideStatusDialog = {},
showStatusDialog = null,
onStatusClick = {},
snackbarHostState = SnackbarHostState(),
isFiatConversionEnabled = isShowFiatConversion,
isUpdateAvailable = false,
isShowingErrorDialog = false,

View File

@ -31,8 +31,7 @@ class WalletDisplayValuesTest {
WalletDisplayValues.getNextValues(
context = getAppContext(),
walletSnapshot = walletSnapshot,
isUpdateAvailable = false,
isDetailedStatus = false
isUpdateAvailable = false
)
assertNotNull(values)

View File

@ -3,6 +3,7 @@ package co.electriccoin.zcash.ui.screen.update.util
import android.content.Intent
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.ui.test.getAppContext
import co.electriccoin.zcash.ui.util.PlayStoreUtil
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test

View File

@ -80,6 +80,7 @@ sealed class BalanceState(open val totalBalance: Zatoshi) {
}
@Composable
@Suppress("LongMethod")
fun BalanceWidget(
balanceState: BalanceState,
isReferenceToBalances: Boolean,
@ -104,11 +105,22 @@ fun BalanceWidget(
text = stringResource(id = co.electriccoin.zcash.ui.R.string.balance_widget_available),
onClick = onReferenceClick,
fontWeight = FontWeight.Normal,
modifier = Modifier.padding(all = ZcashTheme.dimens.spacingTiny)
modifier =
Modifier
.padding(
vertical = ZcashTheme.dimens.spacingSmall,
horizontal = ZcashTheme.dimens.spacingMini,
)
)
} else {
Body(
text = stringResource(id = co.electriccoin.zcash.ui.R.string.balance_widget_available),
modifier =
Modifier
.padding(
vertical = ZcashTheme.dimens.spacingSmall,
horizontal = ZcashTheme.dimens.spacingMini,
)
)
}

View File

@ -1,13 +1,19 @@
package co.electriccoin.zcash.ui.common.compose
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@ -18,11 +24,13 @@ import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.sdk.extension.toPercentageWithDecimal
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.component.AppAlertDialog
import co.electriccoin.zcash.ui.design.component.BodySmall
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.SmallLinearProgressIndicator
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import co.electriccoin.zcash.ui.screen.balances.model.WalletDisplayValues
@Preview(device = Devices.PIXEL_4_XL)
@ -34,8 +42,8 @@ private fun BalanceWidgetPreview() {
) {
SynchronizationStatus(
isUpdateAvailable = false,
isDetailedStatus = false,
walletSnapshot = WalletSnapshotFixture.new()
onStatusClick = {},
walletSnapshot = WalletSnapshotFixture.new(),
)
}
}
@ -44,7 +52,7 @@ private fun BalanceWidgetPreview() {
@Composable
fun SynchronizationStatus(
isUpdateAvailable: Boolean,
isDetailedStatus: Boolean,
onStatusClick: (StatusAction) -> Unit,
walletSnapshot: WalletSnapshot,
modifier: Modifier = Modifier,
testTag: String? = null,
@ -54,7 +62,6 @@ fun SynchronizationStatus(
context = LocalContext.current,
walletSnapshot = walletSnapshot,
isUpdateAvailable = isUpdateAvailable,
isDetailedStatus = isDetailedStatus
)
Column(
@ -64,11 +71,14 @@ fun SynchronizationStatus(
if (walletDisplayValues.statusText.isNotEmpty()) {
BodySmall(
text = walletDisplayValues.statusText,
modifier = testTag?.let { Modifier.testTag(testTag) } ?: Modifier,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
modifier =
Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
.clickable { onStatusClick(walletDisplayValues.statusAction) }
.padding(all = ZcashTheme.dimens.spacingSmall)
.then(testTag?.let { Modifier.testTag(testTag) } ?: Modifier),
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
}
BodySmall(
@ -86,8 +96,27 @@ fun SynchronizationStatus(
progress = walletSnapshot.progress.decimal,
modifier =
Modifier.padding(
horizontal = ZcashTheme.dimens.spacingUpLarge
horizontal = ZcashTheme.dimens.spacingDefault
)
)
}
}
@Composable
fun StatusDialog(
statusAction: StatusAction.Detailed,
onDone: () -> Unit
) {
AppAlertDialog(
title = stringResource(id = R.string.balances_status_error_dialog_title),
text = {
Column(
Modifier.verticalScroll(rememberScrollState())
) {
Text(text = statusAction.details)
}
},
confirmButtonText = stringResource(id = R.string.balances_status_dialog_button),
onConfirmButtonClick = onDone
)
}

View File

@ -185,8 +185,6 @@ class AuthenticationViewModel(
// Biometric authentication is disabled until the user unlocks with their device credential
// (i.e. PIN, pattern, or password).
BiometricPrompt.ERROR_LOCKOUT_PERMANENT,
// The user does not have any biometrics enrolled
BiometricPrompt.ERROR_NO_BIOMETRICS,
// The device does not have the required authentication hardware
BiometricPrompt.ERROR_HW_NOT_PRESENT,
// The user pressed the negative button
@ -214,9 +212,12 @@ class AuthenticationViewModel(
// We could consider splitting ERROR_CANCELED from ERROR_USER_CANCELED
authenticationResult.value = AuthenticationResult.Canceled
}
// The user does not have any biometrics enrolled
BiometricPrompt.ERROR_NO_BIOMETRICS,
// The device does not have pin, pattern, or password set up
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
// Allow unauthenticated access if no authentication method is available on the device
// These 2 errors can come for a different Android SDK versions, but they mean the same
authenticationResult.value = AuthenticationResult.Success
}
}
@ -346,7 +347,7 @@ class AuthenticationViewModel(
}
else -> {
Twig.error { "Unexpected biometric framework status" }
BiometricSupportResult.StatusExpected
BiometricSupportResult.StatusUnexpected
}
}
}
@ -411,5 +412,5 @@ private sealed class BiometricSupportResult {
data object StatusUnknown : BiometricSupportResult()
data object StatusExpected : BiometricSupportResult()
data object StatusUnexpected : BiometricSupportResult()
}

View File

@ -5,7 +5,10 @@ package co.electriccoin.zcash.ui.screen.account
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.Synchronizer
@ -21,6 +24,8 @@ import co.electriccoin.zcash.ui.screen.account.model.TransactionUiState
import co.electriccoin.zcash.ui.screen.account.view.Account
import co.electriccoin.zcash.ui.screen.account.view.TrxItemAction
import co.electriccoin.zcash.ui.screen.account.viewmodel.TransactionHistoryViewModel
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import co.electriccoin.zcash.ui.util.PlayStoreUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.annotations.VisibleForTesting
@ -31,8 +36,6 @@ internal fun WrapAccount(
goBalances: () -> Unit,
goSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
val walletViewModel by activity.viewModels<WalletViewModel>()
val transactionHistoryViewModel by activity.viewModels<TransactionHistoryViewModel>()
@ -56,7 +59,6 @@ internal fun WrapAccount(
context = activity.applicationContext,
goBalances = goBalances,
goSettings = goSettings,
scope = scope,
synchronizer = synchronizer,
transactionHistoryViewModel = transactionHistoryViewModel,
transactionsUiState = transactionsUiState,
@ -70,11 +72,10 @@ internal fun WrapAccount(
@Composable
@VisibleForTesting
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod")
internal fun WrapAccount(
balanceState: BalanceState,
context: Context,
scope: CoroutineScope,
goBalances: () -> Unit,
goSettings: () -> Unit,
transactionsUiState: TransactionUiState,
@ -83,6 +84,14 @@ internal fun WrapAccount(
walletRestoringState: WalletRestoringState,
walletSnapshot: WalletSnapshot?
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// We could also improve this by `rememberSaveable` to preserve the dialog after a configuration change. But the
// dialog dismissing in such cases is not critical, and it would require creating StatusAction custom Saver
val showStatusDialog = remember { mutableStateOf<StatusAction.Detailed?>(null) }
if (null == synchronizer || null == walletSnapshot) {
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
@ -92,6 +101,23 @@ internal fun WrapAccount(
Account(
balanceState = balanceState,
transactionsUiState = transactionsUiState,
showStatusDialog = showStatusDialog.value,
hideStatusDialog = { showStatusDialog.value = null },
onStatusClick = { status ->
when (status) {
is StatusAction.Detailed -> showStatusDialog.value = status
StatusAction.AppUpdate -> {
openPlayStoreAppSite(
context = context,
snackbarHostState = snackbarHostState,
scope = scope
)
}
else -> {
// No action required
}
}
},
onTransactionItemAction = { action ->
when (action) {
is TrxItemAction.TransactionIdClick -> {
@ -132,8 +158,26 @@ internal fun WrapAccount(
},
goBalances = goBalances,
goSettings = goSettings,
snackbarHostState = snackbarHostState,
walletRestoringState = walletRestoringState,
walletSnapshot = walletSnapshot
)
}
}
private fun openPlayStoreAppSite(
context: Context,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope
) {
val storeIntent = PlayStoreUtil.newActivityIntent(context)
runCatching {
context.startActivity(storeIntent)
}.onFailure {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.unable_to_open_play_store)
)
}
}
}

View File

@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -19,6 +21,7 @@ import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.compose.BalanceWidget
import co.electriccoin.zcash.ui.common.compose.StatusDialog
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.test.CommonTag
@ -30,6 +33,7 @@ import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.account.AccountTag
import co.electriccoin.zcash.ui.screen.account.fixture.TransactionsFixture
import co.electriccoin.zcash.ui.screen.account.model.TransactionUiState
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
@Preview("Account No History")
@Composable
@ -37,12 +41,16 @@ private fun HistoryLoadingComposablePreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Account(
balanceState = BalanceStateFixture.new(),
goBalances = {},
goSettings = {},
hideStatusDialog = {},
showStatusDialog = null,
onStatusClick = {},
onTransactionItemAction = {},
snackbarHostState = SnackbarHostState(),
transactionsUiState = TransactionUiState.Loading,
walletRestoringState = WalletRestoringState.SYNCING,
balanceState = BalanceStateFixture.new(),
walletSnapshot = WalletSnapshotFixture.new(),
)
}
@ -56,10 +64,14 @@ private fun HistoryListComposablePreview() {
GradientSurface {
@Suppress("MagicNumber")
Account(
balanceState = BalanceState.Available(Zatoshi(123_000_000L), Zatoshi(123_000_000L)),
goBalances = {},
goSettings = {},
balanceState = BalanceState.Available(Zatoshi(123_000_000L), Zatoshi(123_000_000L)),
hideStatusDialog = {},
showStatusDialog = null,
onStatusClick = {},
onTransactionItemAction = {},
snackbarHostState = SnackbarHostState(),
transactionsUiState = TransactionUiState.Done(transactions = TransactionsFixture.new()),
walletRestoringState = WalletRestoringState.NONE,
walletSnapshot = WalletSnapshotFixture.new(),
@ -74,20 +86,30 @@ internal fun Account(
balanceState: BalanceState,
goBalances: () -> Unit,
goSettings: () -> Unit,
hideStatusDialog: () -> Unit,
showStatusDialog: StatusAction.Detailed?,
onStatusClick: (StatusAction) -> Unit,
onTransactionItemAction: (TrxItemAction) -> Unit,
snackbarHostState: SnackbarHostState,
transactionsUiState: TransactionUiState,
walletRestoringState: WalletRestoringState,
walletSnapshot: WalletSnapshot,
) {
Scaffold(topBar = {
AccountTopAppBar(
showRestoring = walletRestoringState == WalletRestoringState.RESTORING,
onSettings = goSettings
)
}) { paddingValues ->
Scaffold(
topBar = {
AccountTopAppBar(
showRestoring = walletRestoringState == WalletRestoringState.RESTORING,
onSettings = goSettings
)
},
snackbarHost = {
SnackbarHost(snackbarHostState)
},
) { paddingValues ->
AccountMainContent(
balanceState = balanceState,
goBalances = goBalances,
onStatusClick = onStatusClick,
onTransactionItemAction = onTransactionItemAction,
transactionState = transactionsUiState,
walletRestoringState = walletRestoringState,
@ -99,6 +121,14 @@ internal fun Account(
// underlying transaction history composable
)
)
// Show synchronization status popup
if (showStatusDialog != null) {
StatusDialog(
statusAction = showStatusDialog,
onDone = hideStatusDialog
)
}
}
}
@ -135,6 +165,7 @@ private fun AccountMainContent(
balanceState: BalanceState,
goBalances: () -> Unit,
onTransactionItemAction: (TrxItemAction) -> Unit,
onStatusClick: (StatusAction) -> Unit,
transactionState: TransactionUiState,
walletRestoringState: WalletRestoringState,
walletSnapshot: WalletSnapshot,
@ -157,6 +188,7 @@ private fun AccountMainContent(
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge))
HistoryContainer(
onStatusClick = onStatusClick,
onTransactionItemAction = onTransactionItemAction,
transactionState = transactionState,
walletRestoringState = walletRestoringState,

View File

@ -62,6 +62,7 @@ import co.electriccoin.zcash.ui.screen.account.model.TransactionUi
import co.electriccoin.zcash.ui.screen.account.model.TransactionUiState
import co.electriccoin.zcash.ui.screen.account.model.TrxItemState
import co.electriccoin.zcash.ui.screen.balances.BalancesTag
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import co.electriccoin.zcash.ui.screen.send.view.DEFAULT_LESS_THAN_FEE
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@ -76,6 +77,7 @@ private fun ComposablePreview() {
GradientSurface {
HistoryContainer(
onTransactionItemAction = {},
onStatusClick = {},
transactionState = TransactionUiState.Loading,
walletRestoringState = WalletRestoringState.SYNCING,
walletSnapshot = WalletSnapshotFixture.new()
@ -92,6 +94,7 @@ private fun ComposableHistoryListPreview() {
HistoryContainer(
transactionState = TransactionUiState.Done(transactions = TransactionsFixture.new()),
onTransactionItemAction = {},
onStatusClick = {},
walletRestoringState = WalletRestoringState.RESTORING,
walletSnapshot = WalletSnapshotFixture.new()
)
@ -107,7 +110,9 @@ private val dateFormat: DateFormat by lazy {
}
@Composable
@Suppress("LongParameterList")
internal fun HistoryContainer(
onStatusClick: (StatusAction) -> Unit,
onTransactionItemAction: (TrxItemAction) -> Unit,
transactionState: TransactionUiState,
walletRestoringState: WalletRestoringState,
@ -127,15 +132,19 @@ internal fun HistoryContainer(
Column(
modifier = Modifier.background(color = ZcashTheme.colors.historySyncingColor)
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
// Do not show the app update information and the detailed sync status in the restoring status
// on Account screen
// Do not calculate and use the app update information here, as the sync bar won't be displayed after
// the wallet is fully restored
SynchronizationStatus(
isDetailedStatus = false,
isUpdateAvailable = false,
onStatusClick = onStatusClick,
testTag = BalancesTag.STATUS,
walletSnapshot = walletSnapshot,
modifier =
Modifier
.padding(horizontal = ZcashTheme.dimens.spacingDefault)
.animateContentSize()
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))

View File

@ -2,11 +2,14 @@
package co.electriccoin.zcash.ui.screen.balances
import android.content.Context
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
@ -26,11 +29,14 @@ import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.balances.model.ShieldState
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import co.electriccoin.zcash.ui.screen.balances.view.Balances
import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SubmitResult
import co.electriccoin.zcash.ui.screen.sendconfirmation.viewmodel.CreateTransactionsViewModel
import co.electriccoin.zcash.ui.screen.update.AppUpdateCheckerImp
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.util.PlayStoreUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.annotations.VisibleForTesting
@ -38,7 +44,6 @@ import org.jetbrains.annotations.VisibleForTesting
@Composable
internal fun WrapBalances(
activity: ComponentActivity,
isDetailedSyncStatus: Boolean,
goSettings: () -> Unit,
goMultiTrxSubmissionFailure: () -> Unit,
) {
@ -69,7 +74,6 @@ internal fun WrapBalances(
checkUpdateViewModel = checkUpdateViewModel,
goSettings = goSettings,
goMultiTrxSubmissionFailure = goMultiTrxSubmissionFailure,
isDetailedSyncStatus = isDetailedSyncStatus,
spendingKey = spendingKey,
synchronizer = synchronizer,
walletSnapshot = walletSnapshot,
@ -81,14 +85,14 @@ const val DEFAULT_SHIELDING_THRESHOLD = 100000L
@Composable
@VisibleForTesting
@Suppress("LongParameterList", "LongMethod")
// This function should be refactored into smaller chunks
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
internal fun WrapBalances(
balanceState: BalanceState,
checkUpdateViewModel: CheckUpdateViewModel,
createTransactionsViewModel: CreateTransactionsViewModel,
goSettings: () -> Unit,
goMultiTrxSubmissionFailure: () -> Unit,
isDetailedSyncStatus: Boolean,
spendingKey: UnifiedSpendingKey?,
synchronizer: Synchronizer?,
walletSnapshot: WalletSnapshot?,
@ -98,6 +102,8 @@ internal fun WrapBalances(
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// To show information about the app update, if available
val isUpdateAvailable =
checkUpdateViewModel.updateInfo.collectAsStateWithLifecycle().value.let {
@ -130,6 +136,10 @@ internal fun WrapBalances(
setShowErrorDialog(true)
}
// We could also improve this by `rememberSaveable` to preserve the dialog after a configuration change. But the
// dialog dismissing in such cases is not critical, and it would require creating StatusAction custom Saver
val showStatusDialog = remember { mutableStateOf<StatusAction.Detailed?>(null) }
if (null == synchronizer || null == walletSnapshot || null == spendingKey) {
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
@ -140,10 +150,12 @@ internal fun WrapBalances(
balanceState = balanceState,
isFiatConversionEnabled = isFiatConversionEnabled,
isUpdateAvailable = isUpdateAvailable,
isShowingErrorDialog = isShowingErrorDialog,
isDetailedStatus = isDetailedSyncStatus,
onSettings = goSettings,
isShowingErrorDialog = isShowingErrorDialog,
setShowErrorDialog = setShowErrorDialog,
showStatusDialog = showStatusDialog.value,
hideStatusDialog = { showStatusDialog.value = null },
snackbarHostState = snackbarHostState,
onShielding = {
scope.launch {
setShieldState(ShieldState.Running)
@ -201,6 +213,21 @@ internal fun WrapBalances(
}
}
},
onStatusClick = { status ->
when (status) {
is StatusAction.Detailed -> showStatusDialog.value = status
StatusAction.AppUpdate -> {
openPlayStoreAppSite(
context = context,
snackbarHostState = snackbarHostState,
scope = scope
)
}
else -> {
// No action required
}
}
},
shieldState = shieldState,
walletSnapshot = walletSnapshot,
walletRestoringState = walletRestoringState,
@ -208,7 +235,7 @@ internal fun WrapBalances(
}
}
fun updateTransparentBalanceState(
private fun updateTransparentBalanceState(
currentShieldState: ShieldState,
walletSnapshot: WalletSnapshot?
): ShieldState {
@ -219,3 +246,20 @@ fun updateTransparentBalanceState(
else -> currentShieldState
}
}
private fun openPlayStoreAppSite(
context: Context,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope
) {
val storeIntent = PlayStoreUtil.newActivityIntent(context)
runCatching {
context.startActivity(storeIntent)
}.onFailure {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.unable_to_open_play_store)
)
}
}
}

View File

@ -18,20 +18,22 @@ data class WalletDisplayValues(
val progress: PercentDecimal,
val zecAmountText: String,
val statusText: String,
val statusAction: StatusAction = StatusAction.None,
val fiatCurrencyAmountState: FiatCurrencyConversionRateState,
val fiatCurrencyAmountText: String
) {
companion object {
@Suppress("MagicNumber", "LongMethod")
@Suppress("LongMethod")
internal fun getNextValues(
context: Context,
walletSnapshot: WalletSnapshot,
isUpdateAvailable: Boolean = false,
isDetailedStatus: Boolean = false,
): WalletDisplayValues {
var progress = PercentDecimal.ZERO_PERCENT
val zecAmountText = walletSnapshot.totalBalance().toZecString()
var statusText = ""
var statusAction: StatusAction = StatusAction.None
// TODO [#578]: Provide Zatoshi -> USD fiat currency formatting
// TODO [#578]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/578
// We'll ideally provide a "fresh" currencyConversion object here
@ -55,63 +57,63 @@ data class WalletDisplayValues(
)
}
statusText = context.getString(R.string.balances_status_syncing)
statusAction = StatusAction.Syncing
}
Synchronizer.Status.SYNCED -> {
statusText =
if (isUpdateAvailable) {
if (isUpdateAvailable) {
statusText =
context.getString(
R.string.balances_status_update,
context.getString(R.string.app_name)
)
} else {
context.getString(R.string.balances_status_synced)
}
statusAction = StatusAction.AppUpdate
} else {
statusText = context.getString(R.string.balances_status_synced)
statusAction = StatusAction.Synced
}
}
Synchronizer.Status.DISCONNECTED -> {
if (isDetailedStatus) {
statusText =
context.getString(
R.string.balances_status_error_detailed,
context.getString(R.string.balances_status_error_detailed_connection)
)
} else {
statusText =
context.getString(
R.string.balances_status_error_simple,
context.getString(R.string.app_name)
)
}
}
Synchronizer.Status.STOPPED -> {
if (isDetailedStatus) {
statusText = context.getString(R.string.balances_status_detailed_stopped)
} else {
statusText = context.getString(R.string.balances_status_syncing)
}
}
}
// More detailed error message
walletSnapshot.synchronizerError?.let {
if (isDetailedStatus) {
statusText =
context.getString(
R.string.balances_status_error_detailed,
walletSnapshot.synchronizerError.getCauseMessage()
?: context.getString(R.string.balances_status_error_detailed_unknown)
)
} else {
statusText =
context.getString(
R.string.balances_status_error_simple,
context.getString(R.string.app_name)
)
statusAction =
StatusAction.Disconnected(
details = context.getString(R.string.balances_status_error_dialog_connection)
)
}
Synchronizer.Status.STOPPED -> {
statusText = context.getString(R.string.balances_status_syncing)
statusAction =
StatusAction.Stopped(
details = context.getString(R.string.balances_status_dialog_stopped)
)
}
}
// More detailed error message
walletSnapshot.synchronizerError?.let {
statusText =
context.getString(
R.string.balances_status_error_simple,
context.getString(R.string.app_name)
)
statusAction =
StatusAction.Error(
details =
context.getString(
R.string.balances_status_error_dialog_cause,
walletSnapshot.synchronizerError.getCauseMessage()
?: context.getString(R.string.balances_status_error_dialog_unknown)
)
)
}
return WalletDisplayValues(
progress = progress,
zecAmountText = zecAmountText,
statusAction = statusAction,
statusText = statusText,
fiatCurrencyAmountState = fiatCurrencyAmountState,
fiatCurrencyAmountText = fiatCurrencyAmountText
@ -120,6 +122,24 @@ data class WalletDisplayValues(
}
}
sealed class StatusAction {
data object None : StatusAction()
data object Syncing : StatusAction()
data object Synced : StatusAction()
data object AppUpdate : StatusAction()
sealed class Detailed(open val details: String) : StatusAction()
data class Disconnected(override val details: String) : Detailed(details)
data class Stopped(override val details: String) : Detailed(details)
data class Error(override val details: String) : Detailed(details)
}
private fun getFiatCurrencyRateValue(
context: Context,
fiatCurrencyAmountState: FiatCurrencyConversionRateState

View File

@ -26,6 +26,8 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -55,6 +57,7 @@ import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.compose.BalanceWidget
import co.electriccoin.zcash.ui.common.compose.StatusDialog
import co.electriccoin.zcash.ui.common.compose.SynchronizationStatus
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
@ -81,6 +84,7 @@ import co.electriccoin.zcash.ui.fixture.BalanceStateFixture
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.balances.BalancesTag
import co.electriccoin.zcash.ui.screen.balances.model.ShieldState
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import co.electriccoin.zcash.ui.screen.balances.model.WalletDisplayValues
@Preview("Balances")
@ -89,17 +93,20 @@ private fun ComposableBalancesPreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Balances(
onSettings = {},
isDetailedStatus = false,
balanceState = BalanceStateFixture.new(),
isFiatConversionEnabled = false,
isUpdateAvailable = false,
isShowingErrorDialog = false,
hideStatusDialog = {},
showStatusDialog = null,
setShowErrorDialog = {},
onSettings = {},
onShielding = {},
onStatusClick = {},
shieldState = ShieldState.Available,
snackbarHostState = SnackbarHostState(),
walletSnapshot = WalletSnapshotFixture.new(),
walletRestoringState = WalletRestoringState.NONE,
balanceState = BalanceStateFixture.new(),
)
}
}
@ -111,17 +118,20 @@ private fun ComposableBalancesShieldFailurePreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Balances(
onSettings = {},
isDetailedStatus = false,
balanceState = BalanceStateFixture.new(),
isFiatConversionEnabled = false,
isUpdateAvailable = false,
isShowingErrorDialog = true,
hideStatusDialog = {},
showStatusDialog = null,
setShowErrorDialog = {},
onSettings = {},
onShielding = {},
onStatusClick = {},
shieldState = ShieldState.Available,
snackbarHostState = SnackbarHostState(),
walletSnapshot = WalletSnapshotFixture.new(),
walletRestoringState = WalletRestoringState.NONE,
balanceState = BalanceStateFixture.new(),
)
}
}
@ -143,33 +153,41 @@ private fun ComposableBalancesShieldErrorDialogPreview() {
@Suppress("LongParameterList")
@Composable
fun Balances(
onSettings: () -> Unit,
isDetailedStatus: Boolean,
balanceState: BalanceState,
isFiatConversionEnabled: Boolean,
isUpdateAvailable: Boolean,
isShowingErrorDialog: Boolean,
hideStatusDialog: () -> Unit,
showStatusDialog: StatusAction.Detailed?,
setShowErrorDialog: (Boolean) -> Unit,
onSettings: () -> Unit,
onShielding: () -> Unit,
onStatusClick: (StatusAction) -> Unit,
shieldState: ShieldState,
snackbarHostState: SnackbarHostState,
walletSnapshot: WalletSnapshot?,
walletRestoringState: WalletRestoringState,
balanceState: BalanceState,
) {
Scaffold(topBar = {
BalancesTopAppBar(
showRestoring = walletRestoringState == WalletRestoringState.RESTORING,
onSettings = onSettings
)
}) { paddingValues ->
Scaffold(
topBar = {
BalancesTopAppBar(
showRestoring = walletRestoringState == WalletRestoringState.RESTORING,
onSettings = onSettings
)
},
snackbarHost = {
SnackbarHost(snackbarHostState)
},
) { paddingValues ->
if (null == walletSnapshot) {
CircularScreenProgressIndicator()
} else {
BalancesMainContent(
balanceState = balanceState,
isDetailedStatus = isDetailedStatus,
isFiatConversionEnabled = isFiatConversionEnabled,
isUpdateAvailable = isUpdateAvailable,
onShielding = onShielding,
onStatusClick = onStatusClick,
walletSnapshot = walletSnapshot,
shieldState = shieldState,
modifier =
@ -182,6 +200,14 @@ fun Balances(
walletRestoringState = walletRestoringState
)
// Show synchronization status popup
if (showStatusDialog != null) {
StatusDialog(
statusAction = showStatusDialog,
onDone = hideStatusDialog
)
}
// Show shielding error popup
if (isShowingErrorDialog && shieldState is ShieldState.Failed) {
ShieldingErrorDialog(
@ -257,10 +283,10 @@ private fun BalancesTopAppBar(
@Composable
private fun BalancesMainContent(
balanceState: BalanceState,
isDetailedStatus: Boolean,
isFiatConversionEnabled: Boolean,
isUpdateAvailable: Boolean,
onShielding: () -> Unit,
onStatusClick: (StatusAction) -> Unit,
walletSnapshot: WalletSnapshot,
shieldState: ShieldState,
walletRestoringState: WalletRestoringState,
@ -282,7 +308,7 @@ private fun BalancesMainContent(
onReferenceClick = {}
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge))
HorizontalDivider(
color = ZcashTheme.colors.darkDividerColor,
@ -304,11 +330,9 @@ private fun BalancesMainContent(
walletSnapshot = walletSnapshot,
)
Spacer(modifier = Modifier.weight(1f, true))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
if (walletRestoringState == WalletRestoringState.RESTORING) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Small(
text = stringResource(id = R.string.balances_status_restoring_text),
textFontWeight = FontWeight.Medium,
@ -317,17 +341,20 @@ private fun BalancesMainContent(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = ZcashTheme.dimens.spacingDefault)
.padding(horizontal = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
} else {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge))
}
SynchronizationStatus(
walletSnapshot = walletSnapshot,
isUpdateAvailable = isUpdateAvailable,
isDetailedStatus = isDetailedStatus,
testTag = BalancesTag.STATUS
onStatusClick = onStatusClick,
testTag = BalancesTag.STATUS,
walletSnapshot = walletSnapshot,
modifier = Modifier.animateContentSize()
)
}
}
@ -517,7 +544,6 @@ fun BalancesOverview(
context = LocalContext.current,
walletSnapshot = walletSnapshot,
isUpdateAvailable = false,
isDetailedStatus = false
)
Column(Modifier.testTag(BalancesTag.FIAT_CONVERSION)) {

View File

@ -18,7 +18,6 @@ import cash.z.ecc.android.sdk.model.ZecSend
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.RestoreScreenBrightness
import co.electriccoin.zcash.ui.common.model.VersionInfo
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
@ -56,13 +55,6 @@ internal fun MainActivity.WrapHome(
val isRestoringInitialWarningSeen = homeViewModel.isRestoringInitialWarningSeen.collectAsStateWithLifecycle().value
// Detailed sync status info is used if set in configuration or if the app is built as debuggable
// (i.e. mainly in development)
val isDetailedSyncStatus =
homeViewModel.isDetailedSyncStatus.collectAsStateWithLifecycle().value.run {
this ?: false || VersionInfo.new(this@WrapHome).isDebuggable
}
val walletSnapshot = walletViewModel.walletSnapshot.collectAsStateWithLifecycle().value
val walletRestoringState = walletViewModel.walletRestoringState.collectAsStateWithLifecycle().value
@ -98,7 +90,6 @@ internal fun MainActivity.WrapHome(
goSettings = goSettings,
goMultiTrxSubmissionFailure = goMultiTrxSubmissionFailure,
homeScreenIndex = homeScreenIndex,
isDetailedSyncStatus = isDetailedSyncStatus,
isKeepScreenOnWhileSyncing = isKeepScreenOnWhileSyncing,
isShowingRestoreInitDialog = isShowingRestoreInitDialog,
onPageChange = {
@ -120,7 +111,6 @@ internal fun WrapHome(
goScan: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit,
homeScreenIndex: HomeScreenIndex,
isDetailedSyncStatus: Boolean,
isKeepScreenOnWhileSyncing: Boolean?,
isShowingRestoreInitDialog: Boolean,
onPageChange: (HomeScreenIndex) -> Unit,
@ -203,7 +193,6 @@ internal fun WrapHome(
screenContent = {
WrapBalances(
activity = activity,
isDetailedSyncStatus = isDetailedSyncStatus,
goSettings = goSettings,
goMultiTrxSubmissionFailure = goMultiTrxSubmissionFailure
)

View File

@ -16,9 +16,9 @@ import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.CheckUpdateViewModel
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.screen.update.util.PlayStoreUtil
import co.electriccoin.zcash.ui.screen.update.view.Update
import co.electriccoin.zcash.ui.screen.update.viewmodel.UpdateViewModel
import co.electriccoin.zcash.ui.util.PlayStoreUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -111,7 +111,7 @@ internal fun WrapUpdate(
},
onLater = onLaterAction,
onReference = {
openPlayStoreAppPage(
openPlayStoreAppSite(
activity.applicationContext,
snackbarHostState,
scope
@ -120,7 +120,7 @@ internal fun WrapUpdate(
)
}
fun openPlayStoreAppPage(
private fun openPlayStoreAppSite(
context: Context,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope
@ -131,7 +131,7 @@ fun openPlayStoreAppPage(
}.onFailure {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.update_unable_to_open_play_store)
message = context.getString(R.string.unable_to_open_play_store)
)
}
}

View File

@ -234,7 +234,7 @@ private fun UpdateContentContent(
} else {
ImageVector.vectorResource(R.drawable.ic_zashi_logo_update_available)
},
contentDescription = stringResource(id = R.string.update_image_content_description)
contentDescription = null
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingBig))

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.screen.update.util
package co.electriccoin.zcash.ui.util
import android.content.Context
import android.content.Intent

View File

@ -22,14 +22,23 @@
<string name="balances_status_synced">Synced</string>
<string name="balances_status_update">Please update <xliff:g id="app_name" example="Zashi">%1$s</xliff:g> using Google Play</string>
<string name="balances_status_error_simple"><xliff:g id="app_name" example="Zashi">%1$s</xliff:g> encountered an error while syncing, attempting to resolve…</string>
<string name="balances_status_error_detailed" formatted="true">Error: <xliff:g id="error_type" example="Lost connection">%1$s</xliff:g></string>
<string name="balances_status_error_detailed_connection">Disconnected</string>
<string name="balances_status_error_detailed_unknown">Unknown cause</string>
<string name="balances_status_detailed_stopped">Synchronizer stopped</string>
<string name="balances_status_restoring_text">The restore process can take several hours on lower-powered devices, and even on powerful devices is likely to take more than an hour.</string>
<string name="balances_shielding_successful">Shielding has been successfully submitted</string>
<string name="balances_status_error_dialog_title">Error</string>
<string name="balances_status_error_dialog_connection">
Disconnected. Please check your internet connection.
</string>
<string name="balances_status_error_dialog_cause" formatted="true">
Error: <xliff:g id="error_cause" example="Block scanning problem">%1$s</xliff:g>
</string>
<string name="balances_status_error_dialog_unknown">
Unknown cause. Please contact our support team if the problem persists.
</string>
<string name="balances_status_dialog_stopped">Synchronization is stopped. It will resume soon.</string>
<string name="balances_status_dialog_button">OK</string>
<string name="balances_shielding_dialog_error_title">Failed to shield funds</string>
<string name="balances_shielding_dialog_error_text">Error: The attempt to shield the transparent funds failed. Try it again, please.</string>
<string name="balances_shielding_dialog_error_btn">OK</string>

View File

@ -7,4 +7,5 @@
<!-- This is replaced by a resource overlay via app/build.gradle.kts -->
<string name="support_email_address" />
<string name="restoring_wallet_label">[Restoring Your Wallet…]</string>
<string name="unable_to_open_play_store">Unable to launch Google Play store app…</string>
</resources>

View File

@ -2,7 +2,6 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="update_header">Update available</string>
<string name="update_critical_header">Update required</string>
<string name="update_image_content_description"></string>
<string name="update_title_available"><xliff:g id="app_name" example="Zcash">%1$s</xliff:g> here.</string>
<string name="update_title_required">It\'s not you, it\'s me.</string>
<string name="update_description_required">
@ -17,5 +16,4 @@
<string name="update_download_button">Update</string>
<string name="update_later_enabled_button">Remind me later</string>
<string name="update_later_disabled_button">(required)</string>
<string name="update_unable_to_open_play_store">Unable to launch Google Play store app.</string>
</resources>