[#653] Refactor Backup flow screens
* [#653] Move copy to buffer action - Trigger this action after seed phrase panel click and confirm via dialog window - Added basic ui tests - Added also dialog integration test - Added related strings * Revert "[#653] Move copy to buffer action" This reverts commit 813eab00b747a779be5ef652745002f65c04572c. * [#150] Refactoring the Backup flow to use Compose Scaffold * Fix Backup flow screenshot test - Removed scroll actions above nodes, which are no longer part of scroll behaviour - bottom navigation buttons are now part of Compose Scaffold component. * Added scroll actions in screenshot test of Profile screen - After tested the whole app with screenshot test on smaller screen device 4WVGA Nexus S * Remove unnecessary screenshot test click action - This click action on the Profile screen title seems to be unnecessary for the test and creates confusion * ScreenshotTest module auto components init - Changed the way we auto-initialize components in ScreenshotTest module - Now we use androidx-startup library for it - And we disabled the default way * Add system back button navigation support * Enable scrolling for Backup Test screen * Fix Screenshot test on small screen in landscape - Tested and fixed cases in which our screenshot test wasn't successful - Tested on 4 WVGA Nexus S in landscpae mode * Code clean * Address review comments + stages refactoring - Flattened BackupStage sealed class. Test and Failure are now regular stages. - Introduced CheckSeed stage, instead of reusing Seed phrase for re-viewing. - Simplified BackupView and custom Saver implementations. - List of possible screen state stages is now lazy loaded value, instead of method. - Some of the stages now override stage moving methods to enhance Backup screen state machine, as it's not linear. - Existing tests updated to align with the new implementation of stages. * Remove `run` block * Rename CheckSeed -> ReviewSeed Check might imply to another reader of the code that there is some going back to the test. I went with the word Review which seems to better convey how that screen is passive for the user. * Simplify list construction This should have better performance. * Crash instead of allowing back navigation * Add documentation * Fix initialization error * Add non-localized string tag Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
parent
00513a18da
commit
381af575ef
|
@ -130,6 +130,7 @@ ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.2
|
|||
ANDROIDX_TEST_CORE_VERSION=1.5.0
|
||||
ANDROIDX_TEST_MACROBENCHMARK_VERSION=1.2.0-alpha07
|
||||
ANDROIDX_TEST_RUNNER_VERSION=1.5.1
|
||||
ANDROIDX_STARTUP_VERSION=1.1.1
|
||||
ANDROIDX_UI_AUTOMATOR_VERSION=2.2.0-alpha1
|
||||
ANDROIDX_WORK_MANAGER_VERSION=2.7.1
|
||||
CORE_LIBRARY_DESUGARING_VERSION=1.1.6
|
||||
|
|
|
@ -127,6 +127,7 @@ dependencyResolutionManagement {
|
|||
val androidxProfileInstallerVersion = extra["ANDROIDX_PROFILE_INSTALLER_VERSION"].toString()
|
||||
val androidxSecurityCryptoVersion = extra["ANDROIDX_SECURITY_CRYPTO_VERSION"].toString()
|
||||
val androidxSplashScreenVersion = extra["ANDROIDX_SPLASH_SCREEN_VERSION"].toString()
|
||||
val androidxStartupVersion = extra["ANDROIDX_STARTUP_VERSION"].toString()
|
||||
val androidxTestCoreVersion = extra["ANDROIDX_TEST_CORE_VERSION"].toString()
|
||||
val androidxTestJunitVersion = extra["ANDROIDX_TEST_JUNIT_VERSION"].toString()
|
||||
val androidxTestMacrobenchmarkVersion = extra["ANDROIDX_TEST_MACROBENCHMARK_VERSION"].toString()
|
||||
|
@ -176,6 +177,7 @@ dependencyResolutionManagement {
|
|||
library("androidx-profileinstaller", "androidx.profileinstaller:profileinstaller:$androidxProfileInstallerVersion")
|
||||
library("androidx-security-crypto", "androidx.security:security-crypto-ktx:$androidxSecurityCryptoVersion")
|
||||
library("androidx-splash", "androidx.core:core-splashscreen:$androidxSplashScreenVersion")
|
||||
library("androidx-startup", "androidx.startup:startup-runtime:$androidxStartupVersion")
|
||||
library("androidx-viewmodel-compose", "androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
|
||||
library("androidx-workmanager", "androidx.work:work-runtime-ktx:$androidxWorkManagerVersion")
|
||||
library("desugaring", "com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion")
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:label="zcash-ui-integration-test">
|
||||
android:label="zcash-ui-integration-test"
|
||||
android:icon="@mipmap/ic_launcher_square"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
<activity
|
||||
android:name="co.electriccoin.zcash.ui.integration.test.common.IntegrationTestingActivity"
|
||||
android:exported="false" />
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
package co.electriccoin.zcash.ui.screen.backup.integration
|
||||
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
|
@ -15,9 +18,11 @@ import co.electriccoin.zcash.ui.screen.backup.BackupTag
|
|||
import co.electriccoin.zcash.ui.screen.backup.model.BackupStage
|
||||
import co.electriccoin.zcash.ui.screen.backup.view.BackupTestSetup
|
||||
import co.electriccoin.zcash.ui.test.getStringResource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class BackupIntegrationTest : UiTestPrerequisites() {
|
||||
|
||||
|
@ -40,7 +45,7 @@ class BackupIntegrationTest : UiTestPrerequisites() {
|
|||
*/
|
||||
@Test
|
||||
@MediumTest
|
||||
fun current_stage_restoration() {
|
||||
fun backup_state_education_restoration() {
|
||||
val restorationTester = StateRestorationTester(composeTestRule)
|
||||
val testSetup = newTestSetup(BackupStage.EducationOverview)
|
||||
|
||||
|
@ -63,7 +68,7 @@ class BackupIntegrationTest : UiTestPrerequisites() {
|
|||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun selected_choices_restoration() {
|
||||
fun backup_state_test_running_restoration() {
|
||||
val restorationTester = StateRestorationTester(composeTestRule)
|
||||
val testSetup = newTestSetup(BackupStage.Test)
|
||||
|
||||
|
@ -72,6 +77,34 @@ class BackupIntegrationTest : UiTestPrerequisites() {
|
|||
}
|
||||
|
||||
assertEquals(BackupStage.Test, testSetup.getStage())
|
||||
|
||||
val chipText = composeTestRule.getDropdownChipSelectedText(0, 0)
|
||||
|
||||
assertNotNull(chipText)
|
||||
assertNotEquals("Chip text shouldn't be empty.", chipText, "")
|
||||
|
||||
assertEquals(BackupStage.Test, testSetup.getStage())
|
||||
|
||||
restorationTester.emulateSavedInstanceStateRestore()
|
||||
|
||||
assertEquals(BackupStage.Test, testSetup.getStage())
|
||||
|
||||
composeTestRule.onNodeWithText(chipText).also {
|
||||
it.assertExists()
|
||||
it.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun selected_choices_restoration() {
|
||||
val restorationTester = StateRestorationTester(composeTestRule)
|
||||
val testSetup = newTestSetup(BackupStage.Test)
|
||||
|
||||
restorationTester.setContent {
|
||||
testSetup.getDefaultContent()
|
||||
}
|
||||
|
||||
assertEquals(0, testSetup.getOnChoicesCallbackCount())
|
||||
assertEquals(TestChoicesFixture.INITIAL_CHOICES.size, testSetup.getSelectedChoicesCount())
|
||||
|
||||
|
@ -82,15 +115,20 @@ class BackupIntegrationTest : UiTestPrerequisites() {
|
|||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[1].performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.Test, testSetup.getStage())
|
||||
assertEquals(2, testSetup.getOnChoicesCallbackCount())
|
||||
assertEquals(4, testSetup.getSelectedChoicesCount())
|
||||
|
||||
restorationTester.emulateSavedInstanceStateRestore()
|
||||
|
||||
// we test here that the stage and the selected choices count remained unchanged after restore
|
||||
assertEquals(BackupStage.Test, testSetup.getStage())
|
||||
assertEquals(2, testSetup.getOnChoicesCallbackCount())
|
||||
assertEquals(4, testSetup.getSelectedChoicesCount())
|
||||
}
|
||||
}
|
||||
|
||||
fun ComposeContentTestRule.getDropdownChipSelectedText(chipIndex: Int, selectionIndex: Int): String {
|
||||
return onAllNodesWithTag(BackupTag.DROPDOWN_CHIP)[chipIndex].let { chip ->
|
||||
chip.performClick()
|
||||
onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[selectionIndex].performClick()
|
||||
chip.fetchSemanticsNode().config[SemanticsProperties.Text][selectionIndex].text
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,25 +11,25 @@ class BackupStageTest {
|
|||
@Test
|
||||
@SmallTest
|
||||
fun getProgress_first() {
|
||||
val progress = BackupStage.values().first().getProgress()
|
||||
val progress = BackupStage.values.first().getProgress()
|
||||
|
||||
assertEquals(0, progress.current.value)
|
||||
assertEquals(4, progress.last.value)
|
||||
assertEquals(6, progress.last.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getProgress_last() {
|
||||
val progress = BackupStage.values().last().getProgress()
|
||||
val progress = BackupStage.values.last().getProgress()
|
||||
|
||||
assertEquals(4, progress.current.value)
|
||||
assertEquals(4, progress.last.value)
|
||||
assertEquals(6, progress.current.value)
|
||||
assertEquals(6, progress.last.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun hasNext_boundary() {
|
||||
val last = BackupStage.values().last()
|
||||
val last = BackupStage.values.last()
|
||||
|
||||
assertFalse(last.hasNext())
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ class BackupStageTest {
|
|||
@Test
|
||||
@SmallTest
|
||||
fun hasPrevious_boundary() {
|
||||
val last = BackupStage.values().first()
|
||||
val last = BackupStage.values.first()
|
||||
|
||||
assertFalse(last.hasPrevious())
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ class BackupStageTest {
|
|||
@Test
|
||||
@SmallTest
|
||||
fun getNext_from_first() {
|
||||
val first = BackupStage.values().first()
|
||||
val first = BackupStage.values.first()
|
||||
val next = first.getNext()
|
||||
|
||||
assertNotEquals(first, next)
|
||||
|
@ -55,7 +55,7 @@ class BackupStageTest {
|
|||
@Test
|
||||
@SmallTest
|
||||
fun getNext_boundary() {
|
||||
val last = BackupStage.values().last()
|
||||
val last = BackupStage.values.last()
|
||||
|
||||
assertEquals(last, last.getNext())
|
||||
}
|
||||
|
@ -63,17 +63,17 @@ class BackupStageTest {
|
|||
@Test
|
||||
@SmallTest
|
||||
fun getPrevious_from_last() {
|
||||
val last = BackupStage.values().last()
|
||||
val last = BackupStage.values.last()
|
||||
val previous = last.getPrevious()
|
||||
|
||||
assertNotEquals(last, previous)
|
||||
assertEquals(BackupStage.Test, previous)
|
||||
assertEquals(BackupStage.Complete, previous)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getPrevious_boundary() {
|
||||
val first = BackupStage.values().first()
|
||||
val first = BackupStage.values.first()
|
||||
|
||||
assertEquals(first, first.getPrevious())
|
||||
}
|
||||
|
|
|
@ -3,12 +3,13 @@ package co.electriccoin.zcash.ui.screen.backup.view
|
|||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.filters.MediumTest
|
||||
import co.electriccoin.zcash.test.UiTestPrerequisites
|
||||
import co.electriccoin.zcash.ui.R
|
||||
|
@ -56,10 +57,7 @@ class BackupViewTest : UiTestPrerequisites() {
|
|||
fun copy_to_clipboard() {
|
||||
val testSetup = newTestSetup(BackupStage.Seed)
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_3_button_copy)).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
composeTestRule.clickCopyToBuffer()
|
||||
|
||||
assertEquals(1, testSetup.getOnCopyToClipboardCount())
|
||||
}
|
||||
|
@ -109,19 +107,17 @@ class BackupViewTest : UiTestPrerequisites() {
|
|||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[3].performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.Test, testSetup.getStage())
|
||||
assertEquals(BackupStage.Failure, testSetup.getStage())
|
||||
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_4_header_ouch)))
|
||||
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_4_button_retry))).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.Seed, testSetup.getStage())
|
||||
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_3_button_finished))).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
|
@ -129,7 +125,7 @@ class BackupViewTest : UiTestPrerequisites() {
|
|||
|
||||
// These verify that the test itself is re-displayed
|
||||
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_4_header_verify))).also {
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_4_header))).also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
|
@ -152,18 +148,17 @@ class BackupViewTest : UiTestPrerequisites() {
|
|||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun last_stage_click_back_to_seed() {
|
||||
fun complete_stage_click_back_to_seed() {
|
||||
val testSetup = newTestSetup(BackupStage.Complete)
|
||||
|
||||
val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_5_button_back))
|
||||
newWalletButton.also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(0, testSetup.getOnCopyToClipboardCount())
|
||||
assertEquals(0, testSetup.getOnCompleteCallbackCount())
|
||||
assertEquals(BackupStage.Seed, testSetup.getStage())
|
||||
assertEquals(BackupStage.ReviewSeed, testSetup.getStage())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -172,28 +167,22 @@ class BackupViewTest : UiTestPrerequisites() {
|
|||
val testSetup = newTestSetup(BackupStage.EducationOverview)
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_1_button)).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.EducationRecoveryPhrase, testSetup.getStage())
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_2_button)).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(BackupStage.Seed, testSetup.getStage())
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_3_button_copy)).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
composeTestRule.clickCopyToBuffer()
|
||||
|
||||
assertEquals(1, testSetup.getOnCopyToClipboardCount())
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_3_button_finished)).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
|
@ -222,3 +211,18 @@ class BackupViewTest : UiTestPrerequisites() {
|
|||
assertEquals(1, testSetup.getOnCompleteCallbackCount())
|
||||
}
|
||||
}
|
||||
|
||||
fun ComposeContentTestRule.clickCopyToBuffer() {
|
||||
// open menu
|
||||
onNodeWithContentDescription(
|
||||
getStringResource(R.string.new_wallet_toolbar_more_button_content_description)
|
||||
).also { moreMenu ->
|
||||
moreMenu.performClick()
|
||||
// click menu button
|
||||
onNodeWithText(
|
||||
getStringResource(R.string.new_wallet_3_button_copy)
|
||||
).also { menuButton ->
|
||||
menuButton.performClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import android.content.ClipData
|
|||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
@ -16,7 +15,6 @@ import co.electriccoin.zcash.ui.screen.backup.ext.Saver
|
|||
import co.electriccoin.zcash.ui.screen.backup.state.BackupState
|
||||
import co.electriccoin.zcash.ui.screen.backup.state.TestChoices
|
||||
import co.electriccoin.zcash.ui.screen.backup.view.BackupWallet
|
||||
import co.electriccoin.zcash.ui.screen.backup.viewmodel.BackupViewModel
|
||||
|
||||
@Composable
|
||||
internal fun MainActivity.WrapBackup(
|
||||
|
@ -33,26 +31,22 @@ internal fun WrapBackup(
|
|||
persistableWallet: PersistableWallet,
|
||||
onBackupComplete: () -> Unit
|
||||
) {
|
||||
val backupViewModel by activity.viewModels<BackupViewModel>()
|
||||
|
||||
WrapBackup(
|
||||
persistableWallet,
|
||||
backupViewModel.backupState,
|
||||
onCopyToClipboard = { copyToClipboard(activity.applicationContext, persistableWallet) },
|
||||
onBackupComplete = onBackupComplete
|
||||
)
|
||||
}
|
||||
|
||||
// This extra layer of indirection allows unit tests to validate the testChoices state retention.
|
||||
// If backupViewModel goes away eventually, then backupState retention could be tested as well.
|
||||
// This extra layer of indirection allows unit tests to validate the screen state retention.
|
||||
@Composable
|
||||
internal fun WrapBackup(
|
||||
persistableWallet: PersistableWallet,
|
||||
backupState: BackupState,
|
||||
onCopyToClipboard: () -> Unit,
|
||||
onBackupComplete: () -> Unit
|
||||
) {
|
||||
val testChoices by rememberSaveable(stateSaver = TestChoices.Saver) { mutableStateOf(TestChoices()) }
|
||||
val backupState by rememberSaveable(stateSaver = BackupState.Saver) { mutableStateOf(BackupState()) }
|
||||
|
||||
BackupWallet(
|
||||
persistableWallet,
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package co.electriccoin.zcash.ui.screen.backup.ext
|
||||
|
||||
import androidx.compose.runtime.saveable.mapSaver
|
||||
import co.electriccoin.zcash.ui.screen.backup.model.BackupStage
|
||||
import co.electriccoin.zcash.ui.screen.backup.model.values
|
||||
import co.electriccoin.zcash.ui.screen.backup.state.BackupState
|
||||
|
||||
private const val KEY_STAGE = "stage" // $NON-NLS
|
||||
|
||||
internal val BackupState.Companion.Saver
|
||||
get() = mapSaver(
|
||||
save = { it.toSaverMap() },
|
||||
restore = {
|
||||
if (it.isEmpty()) {
|
||||
BackupState()
|
||||
} else {
|
||||
val stage = BackupStage.values[it[KEY_STAGE] as Int]
|
||||
BackupState(stage)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private fun BackupState.toSaverMap() = buildMap {
|
||||
put(KEY_STAGE, current.value.order)
|
||||
}
|
|
@ -4,7 +4,7 @@ import androidx.compose.runtime.saveable.mapSaver
|
|||
import co.electriccoin.zcash.spackle.model.Index
|
||||
import co.electriccoin.zcash.ui.screen.backup.state.TestChoices
|
||||
|
||||
private const val KEY_TEST_CHOICES = "test_choices"
|
||||
private const val KEY_TEST_CHOICES = "test_choices" // $NON-NLS
|
||||
|
||||
// Using a custom saver instead of Parcelize, to avoid adding an Android-specific API to
|
||||
// the TestChoices and Index class
|
||||
|
|
|
@ -3,37 +3,99 @@ package co.electriccoin.zcash.ui.screen.backup.model
|
|||
import co.electriccoin.zcash.spackle.model.Index
|
||||
import co.electriccoin.zcash.spackle.model.Progress
|
||||
|
||||
enum class BackupStage {
|
||||
// Note: the ordinal order is used to manage progression through each stage
|
||||
// so be careful if reordering these
|
||||
EducationOverview,
|
||||
EducationRecoveryPhrase,
|
||||
Seed,
|
||||
Test,
|
||||
Complete;
|
||||
sealed class BackupStage(internal val order: Int) {
|
||||
|
||||
/*
|
||||
* While the backup/restore UX is mostly linear, there are a few branches such as during the
|
||||
* test and at the very end.
|
||||
*
|
||||
* We do not allow the user to back after completing the onboarding, because we don't want to
|
||||
* give users the option to delete their seed from Google Credential Manager (once that feature
|
||||
* is added). Instead, users should go into the app settings to delete a vaulted credential.
|
||||
*
|
||||
* We made the final seed review screen separate from the original seed screen. Although some
|
||||
* code is duplicated, it makes each screen much easier to individually build/test and reduces
|
||||
* risk of bugs being introduced.
|
||||
*/
|
||||
|
||||
companion object
|
||||
|
||||
object EducationOverview : BackupStage(EDUCATION_OVERVIEW_ORDER)
|
||||
object EducationRecoveryPhrase : BackupStage(EDUCATION_RECOVERY_PHRASE_ORDER)
|
||||
object Seed : BackupStage(SEED_ORDER)
|
||||
object Test : BackupStage(TEST_ORDER) {
|
||||
// To bypass the Failure state
|
||||
override fun getNext(): BackupStage {
|
||||
return values[COMPLETE_ORDER]
|
||||
}
|
||||
}
|
||||
|
||||
object Failure : BackupStage(FAILURE_ORDER) {
|
||||
// To let user preview the seed again after test failure
|
||||
override fun getPrevious(): BackupStage {
|
||||
return values[SEED_ORDER]
|
||||
}
|
||||
}
|
||||
|
||||
object Complete : BackupStage(COMPLETE_ORDER) {
|
||||
// To disable back navigation after successful test
|
||||
override fun hasPrevious(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getPrevious(): BackupStage {
|
||||
error("Cannot go back once the onboarding is complete") // $NON-NLS-1$
|
||||
}
|
||||
}
|
||||
|
||||
object ReviewSeed : BackupStage(REVIEW_SEED_ORDER)
|
||||
|
||||
/**
|
||||
* @see getPrevious
|
||||
*/
|
||||
fun hasPrevious() = ordinal > 0
|
||||
open fun hasPrevious() = order > 0
|
||||
|
||||
/**
|
||||
* @see getNext
|
||||
*/
|
||||
fun hasNext() = ordinal < values().size - 1
|
||||
open fun hasNext() = order < values.size - 1
|
||||
|
||||
/**
|
||||
* @return Previous item in ordinal order. Returns the first item when it cannot go further back.
|
||||
*/
|
||||
fun getPrevious() = values()[maxOf(0, ordinal - 1)]
|
||||
open fun getPrevious() = values[maxOf(0, order - 1)]
|
||||
|
||||
/**
|
||||
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
|
||||
*/
|
||||
fun getNext() = values()[minOf(values().size - 1, ordinal + 1)]
|
||||
open fun getNext() = values[minOf(values.size - 1, order + 1)]
|
||||
|
||||
/**
|
||||
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
|
||||
* @return Returns current progression through stages.
|
||||
*/
|
||||
fun getProgress() = Progress(Index(ordinal), Index(values().size - 1))
|
||||
fun getProgress() = Progress(Index(order), Index(values.size - 1))
|
||||
}
|
||||
|
||||
// Note: the indexes are used to manage progression through each stage
|
||||
// so be careful if changing these
|
||||
private const val EDUCATION_OVERVIEW_ORDER = 0
|
||||
private const val EDUCATION_RECOVERY_PHRASE_ORDER = 1
|
||||
private const val SEED_ORDER = 2
|
||||
private const val TEST_ORDER = 3
|
||||
private const val FAILURE_ORDER = 4
|
||||
private const val COMPLETE_ORDER = 5
|
||||
private const val REVIEW_SEED_ORDER = 6
|
||||
|
||||
private val sealedClassValues: List<BackupStage> = buildList<BackupStage>() {
|
||||
add(EDUCATION_OVERVIEW_ORDER, BackupStage.EducationOverview)
|
||||
add(EDUCATION_RECOVERY_PHRASE_ORDER, BackupStage.EducationRecoveryPhrase)
|
||||
add(SEED_ORDER, BackupStage.Seed)
|
||||
add(TEST_ORDER, BackupStage.Test)
|
||||
add(FAILURE_ORDER, BackupStage.Failure)
|
||||
add(COMPLETE_ORDER, BackupStage.Complete)
|
||||
add(REVIEW_SEED_ORDER, BackupStage.ReviewSeed)
|
||||
}
|
||||
|
||||
// https://youtrack.jetbrains.com/issue/KT-8970/Object-is-uninitialized-null-when-accessed-from-static-context-ex.-companion-object-with-initialization-loop
|
||||
val BackupStage.Companion.values: List<BackupStage>
|
||||
get() = sealedClassValues
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package co.electriccoin.zcash.ui.screen.backup.state
|
||||
|
||||
import co.electriccoin.zcash.ui.screen.backup.model.BackupStage
|
||||
import co.electriccoin.zcash.ui.screen.backup.model.values
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
|
@ -9,7 +10,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
* primarily useful on Android, for automated tests, and for iterative debugging with the Compose
|
||||
* layout preview. The default constructor argument is generally fine for other platforms.
|
||||
*/
|
||||
class BackupState(initialState: BackupStage = BackupStage.values().first()) {
|
||||
class BackupState(initialState: BackupStage = BackupStage.values.first()) {
|
||||
|
||||
private val mutableState = MutableStateFlow(initialState)
|
||||
|
||||
|
@ -25,11 +26,9 @@ class BackupState(initialState: BackupStage = BackupStage.values().first()) {
|
|||
mutableState.value = current.value.getPrevious()
|
||||
}
|
||||
|
||||
fun goToBeginning() {
|
||||
mutableState.value = BackupStage.values().first()
|
||||
fun goToStage(newStage: BackupStage) {
|
||||
mutableState.value = newStage
|
||||
}
|
||||
|
||||
fun goToSeed() {
|
||||
mutableState.value = BackupStage.Seed
|
||||
}
|
||||
companion object
|
||||
}
|
||||
|
|
|
@ -1,19 +1,37 @@
|
|||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package co.electriccoin.zcash.ui.screen.backup.view
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
|
@ -31,8 +49,6 @@ import co.electriccoin.zcash.ui.design.component.CHIP_GRID_ROW_SIZE
|
|||
import co.electriccoin.zcash.ui.design.component.Chip
|
||||
import co.electriccoin.zcash.ui.design.component.ChipGrid
|
||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||
import co.electriccoin.zcash.ui.design.component.Header
|
||||
import co.electriccoin.zcash.ui.design.component.NavigationButton
|
||||
import co.electriccoin.zcash.ui.design.component.PrimaryButton
|
||||
import co.electriccoin.zcash.ui.design.component.TertiaryButton
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
|
@ -62,6 +78,7 @@ fun ComposablePreview() {
|
|||
/**
|
||||
* @param onComplete Callback when the user has completed the backup test.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
fun BackupWallet(
|
||||
|
@ -72,32 +89,78 @@ fun BackupWallet(
|
|||
onComplete: () -> Unit,
|
||||
onChoicesChanged: ((choicesCount: Int) -> Unit)?
|
||||
) {
|
||||
when (backupState.current.collectAsState().value) {
|
||||
BackupStage.EducationOverview -> EducationOverview(onNext = backupState::goNext)
|
||||
BackupStage.EducationRecoveryPhrase -> EducationRecoveryPhrase(onNext = backupState::goNext)
|
||||
BackupStage.Seed -> SeedPhrase(
|
||||
wallet,
|
||||
onNext = backupState::goNext,
|
||||
onCopyToClipboard = onCopyToClipboard
|
||||
)
|
||||
BackupStage.Test -> Test(
|
||||
wallet,
|
||||
choices,
|
||||
onBack = backupState::goPrevious,
|
||||
onNext = backupState::goNext,
|
||||
val currentBackupStage = backupState.current.collectAsState().value
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BackupTopAppBar(
|
||||
backupStage = currentBackupStage,
|
||||
onCopyToClipboard = onCopyToClipboard,
|
||||
onBack = backupState::goPrevious,
|
||||
selectedTestChoices = choices
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BackupBottomNav(
|
||||
backupStage = currentBackupStage,
|
||||
onNext = backupState::goNext,
|
||||
onBack = backupState::goPrevious,
|
||||
selectedTestChoices = choices,
|
||||
onComplete = onComplete,
|
||||
onBackToSeedPhrase = {
|
||||
backupState.goToStage(BackupStage.ReviewSeed)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
BackupMainContent(
|
||||
paddingValues = paddingValues,
|
||||
backupState = backupState,
|
||||
wallet = wallet,
|
||||
choices = choices,
|
||||
onChoicesChanged = onChoicesChanged
|
||||
)
|
||||
BackupStage.Complete -> Complete(
|
||||
onComplete = onComplete,
|
||||
onBackToSeedPhrase = backupState::goToSeed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EducationOverview(onNext: () -> Unit) {
|
||||
Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) {
|
||||
Header(stringResource(R.string.new_wallet_1_header))
|
||||
fun BackupMainContent(
|
||||
paddingValues: PaddingValues,
|
||||
backupState: BackupState,
|
||||
wallet: PersistableWallet,
|
||||
choices: TestChoices,
|
||||
onChoicesChanged: ((choicesCount: Int) -> Unit)?
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
bottom = paddingValues.calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
when (backupState.current.collectAsState().value) {
|
||||
is BackupStage.EducationOverview -> EducationOverview()
|
||||
is BackupStage.EducationRecoveryPhrase -> EducationRecoveryPhrase()
|
||||
is BackupStage.Seed -> SeedPhrase(wallet)
|
||||
is BackupStage.Test -> TestInProgress(
|
||||
selectedTestChoices = choices,
|
||||
onChoicesChanged = onChoicesChanged,
|
||||
splitSeedPhrase = wallet.seedPhrase.split,
|
||||
backupState = backupState
|
||||
)
|
||||
is BackupStage.Failure -> TestFailure()
|
||||
is BackupStage.Complete -> TestComplete()
|
||||
is BackupStage.ReviewSeed -> SeedPhrase(wallet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EducationOverview() {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Body(stringResource(R.string.new_wallet_1_body_1))
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.backup_1),
|
||||
|
@ -109,15 +172,15 @@ private fun EducationOverview(onNext: () -> Unit) {
|
|||
.weight(MINIMAL_WEIGHT, true)
|
||||
)
|
||||
Body(stringResource(R.string.new_wallet_1_body_2))
|
||||
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_1_button))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EducationRecoveryPhrase(onNext: () -> Unit) {
|
||||
Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) {
|
||||
Header(stringResource(R.string.new_wallet_2_header))
|
||||
private fun EducationRecoveryPhrase() {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Body(stringResource(R.string.new_wallet_2_body_1))
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.backup_2),
|
||||
|
@ -127,21 +190,19 @@ private fun EducationRecoveryPhrase(onNext: () -> Unit) {
|
|||
Card {
|
||||
Body(stringResource(R.string.new_wallet_2_body_3))
|
||||
}
|
||||
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_2_button))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeedPhrase(persistableWallet: PersistableWallet, onNext: () -> Unit, onCopyToClipboard: () -> Unit) {
|
||||
private fun SeedPhrase(persistableWallet: PersistableWallet) {
|
||||
SecureScreen()
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Header(stringResource(R.string.new_wallet_3_header))
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = ZcashTheme.paddings.padding)
|
||||
) {
|
||||
Body(stringResource(R.string.new_wallet_3_body_1))
|
||||
|
||||
ChipGrid(persistableWallet.seedPhrase.split)
|
||||
|
||||
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_3_button_finished))
|
||||
TertiaryButton(onClick = onCopyToClipboard, text = stringResource(R.string.new_wallet_3_button_copy))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -150,37 +211,6 @@ private val testIndices = listOf(Index(4), Index(9), Index(16), Index(20))
|
|||
|
||||
private data class TestChoice(val originalIndex: Index, val word: String)
|
||||
|
||||
@Composable
|
||||
private fun Test(
|
||||
wallet: PersistableWallet,
|
||||
selectedTestChoices: TestChoices,
|
||||
onBack: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onChoicesChanged: ((choicesCount: Int) -> Unit)?
|
||||
) {
|
||||
SecureScreen()
|
||||
val splitSeedPhrase = wallet.seedPhrase.split
|
||||
|
||||
val currentSelectedTestChoice = selectedTestChoices.current.collectAsState().value
|
||||
|
||||
when {
|
||||
currentSelectedTestChoice.size != testIndices.size -> {
|
||||
TestInProgress(splitSeedPhrase, selectedTestChoices, onBack, onChoicesChanged)
|
||||
}
|
||||
currentSelectedTestChoice.all { splitSeedPhrase[it.key.value] == it.value } -> {
|
||||
// The user got the test correct
|
||||
onNext()
|
||||
}
|
||||
currentSelectedTestChoice.none { null == it.value } -> {
|
||||
TestFailure {
|
||||
// Clear the user's prior test inputs for the retest
|
||||
selectedTestChoices.set(emptyMap())
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A few implementation notes on the test:
|
||||
* - It is possible for the same word to appear twice in the word choices
|
||||
|
@ -190,9 +220,11 @@ private fun Test(
|
|||
private fun TestInProgress(
|
||||
splitSeedPhrase: List<String>,
|
||||
selectedTestChoices: TestChoices,
|
||||
onBack: () -> Unit,
|
||||
onChoicesChanged: ((choicesCount: Int) -> Unit)?
|
||||
onChoicesChanged: ((choicesCount: Int) -> Unit)?,
|
||||
backupState: BackupState
|
||||
) {
|
||||
SecureScreen()
|
||||
|
||||
val testChoices = splitSeedPhrase
|
||||
.mapIndexed { index, word -> TestChoice(Index(index), word) }
|
||||
.filter { testIndices.contains(it.originalIndex) }
|
||||
|
@ -202,45 +234,49 @@ private fun TestInProgress(
|
|||
listOf(it[1], it[0], it[3], it[2])
|
||||
}
|
||||
val currentSelectedTestChoice = selectedTestChoices.current.collectAsState().value
|
||||
Column {
|
||||
// This button doesn't match the design; just providing the navigation hook for now
|
||||
NavigationButton(onClick = onBack, text = stringResource(R.string.new_wallet_4_button_back))
|
||||
if (currentSelectedTestChoice.size == testIndices.size) {
|
||||
if (currentSelectedTestChoice.all { splitSeedPhrase[it.key.value] == it.value }) {
|
||||
// the user got the test correct
|
||||
backupState.goNext()
|
||||
} else {
|
||||
backupState.goToStage(BackupStage.Failure)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = ZcashTheme.paddings.padding)
|
||||
) {
|
||||
splitSeedPhrase.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
chunk.forEachIndexed { subIndex, word ->
|
||||
val currentIndex = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex)
|
||||
|
||||
Header(stringResource(R.string.new_wallet_4_header_verify))
|
||||
// Body(stringResource(R.string.new_wallet_4_body_verify))
|
||||
|
||||
Column {
|
||||
splitSeedPhrase.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
chunk.forEachIndexed { subIndex, word ->
|
||||
val currentIndex = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex)
|
||||
|
||||
if (testIndices.contains(currentIndex)) {
|
||||
ChipDropDown(
|
||||
currentIndex,
|
||||
dropdownText = currentSelectedTestChoice[currentIndex]
|
||||
?: "",
|
||||
choices = testChoices.map { it.word },
|
||||
modifier = Modifier
|
||||
.weight(MINIMAL_WEIGHT)
|
||||
.testTag(BackupTag.DROPDOWN_CHIP)
|
||||
) {
|
||||
selectedTestChoices.set(
|
||||
HashMap(currentSelectedTestChoice).apply {
|
||||
this[currentIndex] = testChoices[it.value].word
|
||||
}
|
||||
)
|
||||
if (onChoicesChanged != null) {
|
||||
onChoicesChanged(selectedTestChoices.current.value.size)
|
||||
if (testIndices.contains(currentIndex)) {
|
||||
ChipDropDown(
|
||||
currentIndex,
|
||||
dropdownText = currentSelectedTestChoice[currentIndex]
|
||||
?: "",
|
||||
choices = testChoices.map { it.word },
|
||||
modifier = Modifier
|
||||
.weight(MINIMAL_WEIGHT)
|
||||
.testTag(BackupTag.DROPDOWN_CHIP)
|
||||
) {
|
||||
selectedTestChoices.set(
|
||||
HashMap(currentSelectedTestChoice).apply {
|
||||
this[currentIndex] = testChoices[it.value].word
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Chip(
|
||||
index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex),
|
||||
text = word,
|
||||
modifier = Modifier.weight(MINIMAL_WEIGHT)
|
||||
)
|
||||
if (onChoicesChanged != null) {
|
||||
onChoicesChanged(selectedTestChoices.current.value.size)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Chip(
|
||||
index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex),
|
||||
text = word,
|
||||
modifier = Modifier.weight(MINIMAL_WEIGHT)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -249,29 +285,26 @@ private fun TestInProgress(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun TestFailure(onBackToSeedPhrase: () -> Unit) {
|
||||
Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) {
|
||||
// This button doesn't match the design; just providing the navigation hook for now
|
||||
NavigationButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_4_button_back))
|
||||
|
||||
Header(stringResource(R.string.new_wallet_4_header_ouch))
|
||||
private fun TestFailure() {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.backup_failure),
|
||||
contentDescription = stringResource(id = R.string.backup_failure_content_description)
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxHeight(MINIMAL_WEIGHT))
|
||||
|
||||
Body(stringResource(R.string.new_wallet_4_body_ouch_retry))
|
||||
|
||||
PrimaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_4_button_retry))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Complete(onComplete: () -> Unit, onBackToSeedPhrase: () -> Unit) {
|
||||
Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) {
|
||||
Header(stringResource(R.string.new_wallet_5_header))
|
||||
private fun TestComplete() {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Body(stringResource(R.string.new_wallet_5_body))
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.backup_success),
|
||||
|
@ -283,7 +316,140 @@ private fun Complete(onComplete: () -> Unit, onBackToSeedPhrase: () -> Unit) {
|
|||
.fillMaxWidth()
|
||||
.weight(MINIMAL_WEIGHT, true)
|
||||
)
|
||||
PrimaryButton(onClick = onComplete, text = stringResource(R.string.new_wallet_5_button_finished))
|
||||
TertiaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_5_button_back))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun BackupTopAppBar(
|
||||
backupStage: BackupStage,
|
||||
onCopyToClipboard: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
selectedTestChoices: TestChoices
|
||||
) {
|
||||
var showCopySeedMenu = false
|
||||
val screenTitleResId = when (backupStage) {
|
||||
is BackupStage.EducationOverview -> {
|
||||
R.string.new_wallet_1_header
|
||||
}
|
||||
is BackupStage.EducationRecoveryPhrase -> {
|
||||
R.string.new_wallet_2_header
|
||||
}
|
||||
is BackupStage.Seed -> {
|
||||
showCopySeedMenu = true
|
||||
R.string.new_wallet_3_header
|
||||
}
|
||||
is BackupStage.Test -> {
|
||||
R.string.new_wallet_4_header
|
||||
}
|
||||
is BackupStage.Failure -> {
|
||||
R.string.new_wallet_4_header_ouch
|
||||
}
|
||||
is BackupStage.Complete -> {
|
||||
R.string.new_wallet_5_header
|
||||
}
|
||||
is BackupStage.ReviewSeed -> {
|
||||
showCopySeedMenu = true
|
||||
R.string.new_wallet_3_header
|
||||
}
|
||||
}
|
||||
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(id = screenTitleResId)) },
|
||||
navigationIcon = {
|
||||
// hide back navigation button for the first and Complete stages
|
||||
if (backupStage.hasPrevious() && backupStage != BackupStage.Complete) {
|
||||
val onBackClickListener = {
|
||||
if (backupStage is BackupStage.Failure) {
|
||||
// Clear the user's prior test inputs for the retest
|
||||
selectedTestChoices.set(emptyMap())
|
||||
}
|
||||
onBack()
|
||||
}
|
||||
BackHandler(enabled = true) { onBackClickListener() }
|
||||
IconButton(onBackClickListener) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(
|
||||
R.string.new_wallet_navigation_back_button_content_description
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (showCopySeedMenu) {
|
||||
CopySeedMenu(onCopyToClipboard)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopySeedMenu(onCopyToClipboard: () -> Unit) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.new_wallet_toolbar_more_button_content_description)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.new_wallet_3_button_copy)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onCopyToClipboard()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun BackupBottomNav(
|
||||
backupStage: BackupStage,
|
||||
onNext: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
selectedTestChoices: TestChoices,
|
||||
onComplete: () -> Unit,
|
||||
onBackToSeedPhrase: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
when (backupStage) {
|
||||
is BackupStage.EducationOverview -> {
|
||||
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_1_button))
|
||||
}
|
||||
is BackupStage.EducationRecoveryPhrase -> {
|
||||
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_2_button))
|
||||
}
|
||||
is BackupStage.Seed -> {
|
||||
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_3_button_finished))
|
||||
}
|
||||
is BackupStage.Test -> {
|
||||
// no bottom navigation button placed
|
||||
}
|
||||
is BackupStage.Failure -> {
|
||||
PrimaryButton(
|
||||
onClick = {
|
||||
// Clear the user's prior test inputs for the retest
|
||||
selectedTestChoices.set(emptyMap())
|
||||
onBack()
|
||||
},
|
||||
text = stringResource(R.string.new_wallet_4_button_retry)
|
||||
)
|
||||
}
|
||||
is BackupStage.Complete -> {
|
||||
PrimaryButton(onClick = onComplete, text = stringResource(R.string.new_wallet_5_button_finished))
|
||||
TertiaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_5_button_back))
|
||||
}
|
||||
is BackupStage.ReviewSeed -> {
|
||||
PrimaryButton(onClick = onBack, text = stringResource(R.string.new_wallet_3_button_finished))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
package co.electriccoin.zcash.ui.screen.backup.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.sdk.ext.collectWith
|
||||
import co.electriccoin.zcash.ui.screen.backup.model.BackupStage
|
||||
import co.electriccoin.zcash.ui.screen.backup.state.BackupState
|
||||
|
||||
class BackupViewModel(application: Application, savedStateHandle: SavedStateHandle) : AndroidViewModel(application) {
|
||||
val backupState: BackupState = run {
|
||||
val initialValue = if (savedStateHandle.contains(KEY_STAGE)) {
|
||||
savedStateHandle.get<BackupStage>(KEY_STAGE)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (null == initialValue) {
|
||||
BackupState()
|
||||
} else {
|
||||
BackupState(initialValue)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// viewModelScope is constructed with Dispatchers.Main.immediate, so this will
|
||||
// update the save state as soon as a change occurs.
|
||||
backupState.current.collectWith(viewModelScope) {
|
||||
savedStateHandle.set(KEY_STAGE, it)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_STAGE = "stage" // $NON-NLS
|
||||
}
|
||||
}
|
|
@ -11,7 +11,6 @@ import co.electriccoin.zcash.spackle.EmulatorWtfUtil
|
|||
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
|
||||
import co.electriccoin.zcash.ui.BuildConfig
|
||||
import co.electriccoin.zcash.ui.MainActivity
|
||||
import co.electriccoin.zcash.ui.screen.backup.viewmodel.BackupViewModel
|
||||
import co.electriccoin.zcash.ui.screen.home.view.Home
|
||||
import co.electriccoin.zcash.ui.screen.home.viewmodel.CheckUpdateViewModel
|
||||
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
|
||||
|
@ -87,9 +86,6 @@ internal fun WrapHome(
|
|||
val onboardingViewModel by activity.viewModels<OnboardingViewModel>()
|
||||
onboardingViewModel.onboardingState.goToBeginning()
|
||||
onboardingViewModel.isImporting.value = false
|
||||
|
||||
val backupViewModel by activity.viewModels<BackupViewModel>()
|
||||
backupViewModel.backupState.goToBeginning()
|
||||
},
|
||||
updateAvailable = updateAvailable
|
||||
)
|
||||
|
|
|
@ -32,7 +32,7 @@ enum class OnboardingStage {
|
|||
fun getNext() = values()[minOf(values().size - 1, ordinal + 1)]
|
||||
|
||||
/**
|
||||
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
|
||||
* @return Returns current progression through stages.
|
||||
*/
|
||||
fun getProgress() = Progress(Index(ordinal), Index(values().size - 1))
|
||||
}
|
||||
|
|
|
@ -7,9 +7,9 @@ import cash.z.ecc.sdk.model.WalletAddress
|
|||
import cash.z.ecc.sdk.model.ZecSend
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
private const val KEY_ADDRESS = "address"
|
||||
private const val KEY_AMOUNT = "amount"
|
||||
private const val KEY_MEMO = "memo"
|
||||
private const val KEY_ADDRESS = "address" // $NON-NLS
|
||||
private const val KEY_AMOUNT = "amount" // $NON-NLS
|
||||
private const val KEY_MEMO = "memo" // $NON-NLS
|
||||
|
||||
// Using a custom saver instead of Parcelize, to avoid adding an Android-specific API to
|
||||
// the ZecSend class
|
||||
|
|
|
@ -7,7 +7,6 @@ import androidx.activity.viewModels
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import co.electriccoin.zcash.ui.MainActivity
|
||||
import co.electriccoin.zcash.ui.screen.backup.viewmodel.BackupViewModel
|
||||
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
|
||||
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
||||
import co.electriccoin.zcash.ui.screen.settings.view.Settings
|
||||
|
@ -48,9 +47,6 @@ private fun WrapSettings(
|
|||
val onboardingViewModel by activity.viewModels<OnboardingViewModel>()
|
||||
onboardingViewModel.onboardingState.goToBeginning()
|
||||
onboardingViewModel.isImporting.value = false
|
||||
|
||||
val backupViewModel by activity.viewModels<BackupViewModel>()
|
||||
backupViewModel.backupState.goToBeginning()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
<resources>
|
||||
<string name="new_wallet_clipboard_tag">Zcash Seed Phrase</string>
|
||||
|
||||
<string name="new_wallet_navigation_back_button_content_description">Back</string>
|
||||
<string name="new_wallet_toolbar_more_button_content_description">More</string>
|
||||
|
||||
<string name="new_wallet_1_header">First things first</string>
|
||||
<string name="new_wallet_1_body_1">It is important to understand that you are in charge here. Great, right? YOU get to be the bank!</string>
|
||||
<string name="new_wallet_1_body_2">But it also means that YOU are the customer, and you need to be self-reliant.\n\nSo how do you recover funds that you‘ve hidden on a complete decentralized and private block-chain?</string>
|
||||
|
@ -17,13 +20,12 @@
|
|||
<string name="new_wallet_3_button_finished">Finished!</string>
|
||||
<string name="new_wallet_3_button_copy">Copy to buffer</string>
|
||||
|
||||
<string name="new_wallet_4_header_verify">Verify Your Backup</string>
|
||||
<string name="new_wallet_4_header">Verify Your Backup</string>
|
||||
<string name="new_wallet_4_header_ouch">Ouch, sorry, no.</string>
|
||||
<string name="new_wallet_4_body_verify">Drag the words below to match your backed-up copy.</string>
|
||||
<string name="new_wallet_4_body_ouch">Your placed words did not match your secret recovery phrase.</string>
|
||||
<string name="new_wallet_4_body_ouch_retry">Your placed words did not match your secret recovery phrase.\n\nRemember, you can‘t recover your funds if you lose (or incorrectly save) these 24 words.</string>
|
||||
<string name="new_wallet_4_button_retry">I‘m ready to try again</string>
|
||||
<string name="new_wallet_4_button_back">Back</string>
|
||||
|
||||
<string name="new_wallet_5_header">Success!</string>
|
||||
<string name="new_wallet_5_body">Place that backup somewhere safe and venture forth in security.</string>
|
||||
|
|
|
@ -67,6 +67,7 @@ dependencies {
|
|||
|
||||
implementation(libs.androidx.compose.test.junit)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.startup)
|
||||
implementation(libs.androidx.uiAutomator)
|
||||
|
||||
if (isOrchestratorEnabled) {
|
||||
|
|
|
@ -1,4 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher_square"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
|
||||
<!-- Now we use androidx-startup for components auto-initialization -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
</application>
|
||||
</manifest>
|
|
@ -233,6 +233,7 @@ class ScreenshotTest : UiTestPrerequisites() {
|
|||
|
||||
// Settings is a subscreen of profile
|
||||
composeTestRule.onNode(hasText(resContext.getString(R.string.profile_settings))).also {
|
||||
it.performScrollTo()
|
||||
it.assertExists()
|
||||
it.performClick()
|
||||
}
|
||||
|
@ -246,6 +247,7 @@ class ScreenshotTest : UiTestPrerequisites() {
|
|||
|
||||
// Address Details is a subscreen of profile
|
||||
composeTestRule.onNode(hasText(resContext.getString(R.string.profile_see_address_details))).also {
|
||||
it.performScrollTo()
|
||||
it.assertExists()
|
||||
it.performClick()
|
||||
}
|
||||
|
@ -273,7 +275,6 @@ class ScreenshotTest : UiTestPrerequisites() {
|
|||
|
||||
composeTestRule.onNode(hasText(resContext.getString(R.string.profile_title))).also {
|
||||
it.assertExists()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
// About is a subscreen of profile
|
||||
|
@ -309,6 +310,7 @@ class ScreenshotTest : UiTestPrerequisites() {
|
|||
|
||||
composeTestRule.onNode(hasText(resContext.getString(R.string.home_button_send))).also {
|
||||
it.assertExists()
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.synchronizer.value != null }
|
||||
|
@ -367,7 +369,6 @@ private fun backupScreenshots(resContext: Context, tag: String, composeTestRule:
|
|||
ScreenshotTest.takeScreenshot(tag, "Backup 1")
|
||||
|
||||
composeTestRule.onNodeWithText(resContext.getString(R.string.new_wallet_1_button)).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
|
@ -377,7 +378,6 @@ private fun backupScreenshots(resContext: Context, tag: String, composeTestRule:
|
|||
ScreenshotTest.takeScreenshot(tag, "Backup 2")
|
||||
|
||||
composeTestRule.onNodeWithText(resContext.getString(R.string.new_wallet_2_button)).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
|
@ -387,26 +387,29 @@ private fun backupScreenshots(resContext: Context, tag: String, composeTestRule:
|
|||
ScreenshotTest.takeScreenshot(tag, "Backup 3")
|
||||
|
||||
composeTestRule.onNodeWithText(resContext.getString(R.string.new_wallet_3_button_finished)).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(resContext.getString(R.string.new_wallet_4_header_verify)).also {
|
||||
composeTestRule.onNodeWithText(resContext.getString(R.string.new_wallet_4_header)).also {
|
||||
it.assertExists()
|
||||
}
|
||||
ScreenshotTest.takeScreenshot(tag, "Backup 4")
|
||||
|
||||
// Fail test first
|
||||
composeTestRule.onAllNodesWithTag(BackupTag.DROPDOWN_CHIP).also {
|
||||
it[0].performScrollTo()
|
||||
it[0].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[0].performClick()
|
||||
|
||||
it[1].performScrollTo()
|
||||
it[1].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[1].performClick()
|
||||
|
||||
it[2].performScrollTo()
|
||||
it[2].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[2].performClick()
|
||||
|
||||
it[3].performScrollTo()
|
||||
it[3].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[3].performClick()
|
||||
}
|
||||
|
@ -416,7 +419,6 @@ private fun backupScreenshots(resContext: Context, tag: String, composeTestRule:
|
|||
}
|
||||
|
||||
composeTestRule.onNode(hasText(resContext.getString(R.string.new_wallet_4_button_retry))).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
|
@ -424,26 +426,29 @@ private fun backupScreenshots(resContext: Context, tag: String, composeTestRule:
|
|||
it.assertExists()
|
||||
}
|
||||
composeTestRule.onNodeWithText(resContext.getString(R.string.new_wallet_3_button_finished)).also {
|
||||
it.performScrollTo()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(resContext.getString(R.string.new_wallet_4_header_verify)).also {
|
||||
composeTestRule.onNodeWithText(resContext.getString(R.string.new_wallet_4_header)).also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
composeTestRule.onAllNodesWithTag(BackupTag.DROPDOWN_CHIP).also {
|
||||
it.assertCountEquals(4)
|
||||
|
||||
it[0].performScrollTo()
|
||||
it[0].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[1].performClick()
|
||||
|
||||
it[1].performScrollTo()
|
||||
it[1].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[0].performClick()
|
||||
|
||||
it[2].performScrollTo()
|
||||
it[2].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[3].performClick()
|
||||
|
||||
it[3].performScrollTo()
|
||||
it[3].performClick()
|
||||
composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[2].performClick()
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue