[#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 ### Added
- Grid pattern background has been added to several screens - 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 ### Changed
- The color palette used across the app has been reworked to align with the updated design document - 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.compose.runtime.saveable.rememberSaveable
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.ZecSend 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.MULTIPLE_SUBMISSION_CLEAR_FORM
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_AMOUNT import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_INITIAL_STAGE 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.authentication.WrapAuthentication
import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer
import co.electriccoin.zcash.ui.screen.deletewallet.WrapDeleteWallet 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.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.home.WrapHome import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator
@ -88,42 +93,7 @@ internal fun MainActivity.Navigation() {
popExitTransition = { popExitTransition() } popExitTransition = { popExitTransition() }
) { ) {
composable(HOME) { backStack -> composable(HOME) { backStack ->
WrapHome( NavigationHome(navController, backStack)
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()
}
} }
composable(SETTINGS) { composable(SETTINGS) {
WrapSettings( 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 @Composable
private fun MainActivity.ShowSystemAuthentication( private fun MainActivity.ShowSystemAuthentication(
navHostController: NavHostController, navHostController: NavHostController,

View File

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

View File

@ -11,7 +11,13 @@ data class TransactionOverviewExt(
val recipientAddressType: AddressType? 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 // Non-null assertion operator is necessary here as the smart cast to is impossible because `minedHeight` and
// `expiryHeight` are declared in a different module // `expiryHeight` are declared in a different module
return when { 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="support_email_address" />
<string name="restoring_wallet_label">[Restoring Your Wallet…]</string> <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="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> </resources>