[#1458] Detect unavailable service and show a dialog

* [#1458] Detect unavailable service and show a dialog

- Closes #1458
- Changelog update

* Provide unsorted trx list when disconnected

- These changes also improve the app’s ability to use the SDK’s APIs to provide a transaction history list, even in a disconnected state. In such a case, the list is sorted by its original order.
- Changelog update
This commit is contained in:
Honza Rychnovský 2024-06-03 18:41:06 +02:00 committed by GitHub
parent 75e90607d1
commit d813c1a9da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 230 additions and 39 deletions

View File

@ -11,6 +11,9 @@ directly impact users rather than highlighting other key architectural updates.*
### Added
- Grid pattern background has been added to several screens
- A new disconnected dialog reminder has been added to inform users about possible server issues
- The transaction history list will be displayed when the app has server connection issues. Such a list might have a
slightly different order.
### Changed
- The color palette used across the app has been reworked to align with the updated design document

View File

@ -5,13 +5,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.ZecSend
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.NavigationArguments.MULTIPLE_SUBMISSION_CLEAR_FORM
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_INITIAL_STAGE
@ -43,6 +47,7 @@ import co.electriccoin.zcash.ui.screen.authentication.AuthenticationUseCase
import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication
import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer
import co.electriccoin.zcash.ui.screen.deletewallet.WrapDeleteWallet
import co.electriccoin.zcash.ui.screen.disconnected.WrapDisconnected
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator
@ -88,42 +93,7 @@ internal fun MainActivity.Navigation() {
popExitTransition = { popExitTransition() }
) {
composable(HOME) { backStack ->
WrapHome(
goBack = { finish() },
goScan = { navController.navigateJustOnce(SCAN) },
goSendConfirmation = { zecSend ->
navController.currentBackStackEntry?.savedStateHandle?.let { handle ->
fillInHandleForConfirmation(handle, zecSend, SendConfirmationStage.Confirmation)
}
navController.navigateJustOnce(SEND_CONFIRMATION)
},
goSettings = { navController.navigateJustOnce(SETTINGS) },
goMultiTrxSubmissionFailure = {
// Ultimately we could approach reworking the MultipleTrxFailure screen into a separate
// navigation endpoint
navController.currentBackStackEntry?.savedStateHandle?.let { handle ->
fillInHandleForConfirmation(handle, null, SendConfirmationStage.MultipleTrxFailure)
}
navController.navigateJustOnce(SEND_CONFIRMATION)
},
sendArguments =
SendArguments(
recipientAddress =
backStack.savedStateHandle.get<String>(SEND_SCAN_RECIPIENT_ADDRESS)?.let {
Json.decodeFromString<SerializableAddress>(it).toRecipient()
},
clearForm = backStack.savedStateHandle.get<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM) ?: false
).also {
// Remove Send screen arguments passed from the Scan or MultipleSubmissionFailure screens if
// some exist after we use them
backStack.savedStateHandle.remove<String>(SEND_SCAN_RECIPIENT_ADDRESS)
backStack.savedStateHandle.remove<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM)
},
)
if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) {
WrapCheckForUpdate()
}
NavigationHome(navController, backStack)
}
composable(SETTINGS) {
WrapSettings(
@ -280,6 +250,65 @@ internal fun MainActivity.Navigation() {
}
}
/**
* This is the Home screens sub-navigation. We could consider creating a separate sub-navigation graph.
*/
@Composable
private fun MainActivity.NavigationHome(
navController: NavHostController,
backStack: NavBackStackEntry
) {
WrapHome(
goBack = { finish() },
goScan = { navController.navigateJustOnce(SCAN) },
goSendConfirmation = { zecSend ->
navController.currentBackStackEntry?.savedStateHandle?.let { handle ->
fillInHandleForConfirmation(handle, zecSend, SendConfirmationStage.Confirmation)
}
navController.navigateJustOnce(SEND_CONFIRMATION)
},
goSettings = { navController.navigateJustOnce(SETTINGS) },
goMultiTrxSubmissionFailure = {
// Ultimately we could approach reworking the MultipleTrxFailure screen into a separate
// navigation endpoint
navController.currentBackStackEntry?.savedStateHandle?.let { handle ->
fillInHandleForConfirmation(handle, null, SendConfirmationStage.MultipleTrxFailure)
}
navController.navigateJustOnce(SEND_CONFIRMATION)
},
sendArguments =
SendArguments(
recipientAddress =
backStack.savedStateHandle.get<String>(SEND_SCAN_RECIPIENT_ADDRESS)?.let {
Json.decodeFromString<SerializableAddress>(it).toRecipient()
},
clearForm = backStack.savedStateHandle.get<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM) ?: false
).also {
// Remove Send screen arguments passed from the Scan or MultipleSubmissionFailure screens if
// some exist after we use them
backStack.savedStateHandle.remove<String>(SEND_SCAN_RECIPIENT_ADDRESS)
backStack.savedStateHandle.remove<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM)
},
)
val sdkStatus = walletViewModel.walletSnapshot.collectAsStateWithLifecycle().value?.status
if (Synchronizer.Status.DISCONNECTED == sdkStatus) {
Twig.info { "Disconnected state received from Synchronizer" }
WrapDisconnected(
goChooseServer = {
navController.navigateJustOnce(CHOOSE_SERVER)
},
onIgnore = {
// Keep the current navigation location
}
)
} else if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) {
WrapCheckForUpdate()
}
}
@Composable
private fun MainActivity.ShowSystemAuthentication(
navHostController: NavHostController,

View File

@ -220,11 +220,11 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
combine(
synchronizer.transactions,
synchronizer.status,
synchronizer.networkHeight.filterNotNull()
synchronizer.networkHeight
) {
transactions: List<TransactionOverview>,
status: Synchronizer.Status,
networkHeight: BlockHeight ->
networkHeight: BlockHeight? ->
val enhancedTransactions =
transactions
.sortedByDescending {

View File

@ -11,7 +11,13 @@ data class TransactionOverviewExt(
val recipientAddressType: AddressType?
)
fun TransactionOverview.getSortHeight(networkHeight: BlockHeight): BlockHeight {
/**
* This extension provides the best height that can currently be offered.
*
* @return It returns a height for the transaction list sorting in this order:
* [minedHeight] -> [expiryHeight] -> [networkHeight] -> null
*/
fun TransactionOverview.getSortHeight(networkHeight: BlockHeight?): BlockHeight? {
// Non-null assertion operator is necessary here as the smart cast to is impossible because `minedHeight` and
// `expiryHeight` are declared in a different module
return when {

View File

@ -0,0 +1,43 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.disconnected
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.screen.disconnected.model.DisconnectedUiState
import co.electriccoin.zcash.ui.screen.disconnected.view.ServerDisconnected
@Composable
internal fun MainActivity.WrapDisconnected(
goChooseServer: () -> Unit,
onIgnore: () -> Unit,
) {
co.electriccoin.zcash.ui.screen.disconnected.WrapDisconnected(
goChooseServer = goChooseServer,
onIgnore = onIgnore,
)
}
@Composable
private fun WrapDisconnected(
goChooseServer: () -> Unit,
onIgnore: () -> Unit,
) {
val (disconnectedUi, setDisconnectedUi) =
rememberSaveable(stateSaver = DisconnectedUiState.Saver) { mutableStateOf(DisconnectedUiState.Displayed) }
if (disconnectedUi == DisconnectedUiState.Displayed) {
ServerDisconnected(
onChooseServer = {
setDisconnectedUi(DisconnectedUiState.Dismissed)
goChooseServer()
},
onIgnore = {
setDisconnectedUi(DisconnectedUiState.Dismissed)
onIgnore()
},
)
}
}

View File

@ -0,0 +1,44 @@
package co.electriccoin.zcash.ui.screen.disconnected.model
import androidx.compose.runtime.saveable.mapSaver
internal sealed class DisconnectedUiState {
data object Displayed : DisconnectedUiState()
data object Dismissed : DisconnectedUiState()
companion object {
private const val TYPE_DISPLAYED = "displayed" // $NON-NLS
private const val TYPE_DISMISSED = "dismissed" // $NON-NLS
private const val KEY_TYPE = "type" // $NON-NLS
internal val Saver
get() =
run {
mapSaver(
save = { it.toSaverMap() },
restore = {
if (it.isEmpty()) {
null
} else {
val sendStageString = (it[KEY_TYPE] as String)
when (sendStageString) {
TYPE_DISPLAYED -> Displayed
TYPE_DISMISSED -> Dismissed
else -> null
}
}
}
)
}
private fun DisconnectedUiState.toSaverMap(): HashMap<String, String> {
val saverMap = HashMap<String, String>()
when (this) {
Displayed -> saverMap[KEY_TYPE] = TYPE_DISPLAYED
Dismissed -> saverMap[KEY_TYPE] = TYPE_DISMISSED
}
return saverMap
}
}
}

View File

@ -0,0 +1,59 @@
package co.electriccoin.zcash.ui.screen.disconnected.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.AppAlertDialog
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Preview("Server Disconnected")
@Composable
private fun PreviewServerDisconnected() {
ZcashTheme(forceDarkMode = false) {
BlankSurface {
ServerDisconnected(
onChooseServer = {},
onIgnore = {}
)
}
}
}
@Composable
fun ServerDisconnected(
onChooseServer: () -> Unit,
onIgnore: () -> Unit,
) {
ServerDisconnectedDialog(
onChooseServer = onChooseServer,
onIgnore = onIgnore,
)
}
@Composable
fun ServerDisconnectedDialog(
onChooseServer: () -> Unit,
onIgnore: () -> Unit,
) {
AppAlertDialog(
title = stringResource(id = R.string.server_disconnected_dialog_title),
text = {
Column(
Modifier.verticalScroll(rememberScrollState())
) {
Text(text = stringResource(id = R.string.server_disconnected_dialog_message))
}
},
confirmButtonText = stringResource(id = R.string.server_disconnected_dialog_switch_btn),
onConfirmButtonClick = onChooseServer,
dismissButtonText = stringResource(id = R.string.server_disconnected_dialog_ignore_btn),
onDismissButtonClick = onIgnore,
)
}

View File

@ -8,4 +8,11 @@
<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>
<string name="server_disconnected_dialog_title">Caution</string>
<string name="server_disconnected_dialog_message">The server you\'re presently connected to is experiencing
difficulties. For better performance, navigate to Advanced Settings and choose a different server. Please
also check your device connection.</string>
<string name="server_disconnected_dialog_switch_btn">Switch Server</string>
<string name="server_disconnected_dialog_ignore_btn">Ignore</string>
</resources>