[#763] Configure simplified onboarding

This commit is contained in:
Carter Jernigan 2023-02-21 13:28:03 -05:00 committed by Carter Jernigan
parent 9f9ee0fcc1
commit a806d2defa
9 changed files with 138 additions and 71 deletions

View File

@ -11,8 +11,17 @@ import java.util.concurrent.atomic.AtomicInteger
class OnboardingTestSetup( class OnboardingTestSetup(
private val composeTestRule: ComposeContentTestRule, private val composeTestRule: ComposeContentTestRule,
private val isFullOnboardingEnabled: Boolean,
initialStage: OnboardingStage initialStage: OnboardingStage
) { ) {
init {
if (!isFullOnboardingEnabled) {
require(initialStage == OnboardingStage.Wallet) {
"When full onboarding is disabled, the initial stage must be Wallet"
}
}
}
private val onboardingState = OnboardingState(initialStage) private val onboardingState = OnboardingState(initialStage)
private val onCreateWalletCallbackCount = AtomicInteger(0) private val onCreateWalletCallbackCount = AtomicInteger(0)
@ -38,6 +47,7 @@ class OnboardingTestSetup(
fun getDefaultContent() { fun getDefaultContent() {
ZcashTheme { ZcashTheme {
Onboarding( Onboarding(
isFullOnboardingEnabled,
onboardingState, onboardingState,
isDebugMenuEnabled = false, isDebugMenuEnabled = false,
onCreateWallet = { onCreateWalletCallbackCount.incrementAndGet() }, onCreateWallet = { onCreateWalletCallbackCount.incrementAndGet() },

View File

@ -18,7 +18,11 @@ class OnboardingActivityTest : UiTestPrerequisites() {
@get:Rule @get:Rule
val composeTestRule = createAndroidComposeRule<UiTestingActivity>() val composeTestRule = createAndroidComposeRule<UiTestingActivity>()
private fun newTestSetup() = OnboardingTestSetup(composeTestRule, OnboardingStage.ShieldedByDefault) private fun newTestSetup() = OnboardingTestSetup(
composeTestRule,
isFullOnboardingEnabled = true,
OnboardingStage.ShieldedByDefault
)
@Test @Test
@MediumTest @MediumTest

View File

@ -18,7 +18,11 @@ class OnboardingIntegrationTest : UiTestPrerequisites() {
@get:Rule @get:Rule
val composeTestRule = createComposeRule() val composeTestRule = createComposeRule()
private fun newTestSetup(initialStage: OnboardingStage) = OnboardingTestSetup(composeTestRule, initialStage) private fun newTestSetup(initialStage: OnboardingStage) = OnboardingTestSetup(
composeTestRule,
isFullOnboardingEnabled = true,
initialStage
)
/** /**
* The test semantics are built upon StateRestorationTester component. We simulate screen state * The test semantics are built upon StateRestorationTester component. We simulate screen state

View File

@ -20,8 +20,8 @@ class OnboardingViewTest : UiTestPrerequisites() {
@get:Rule @get:Rule
val composeTestRule = createComposeRule() val composeTestRule = createComposeRule()
private fun newTestSetup(initialStage: OnboardingStage): OnboardingTestSetup { private fun newTestSetup(isFullOnboardingEnabled: Boolean = true, initialStage: OnboardingStage): OnboardingTestSetup {
return OnboardingTestSetup(composeTestRule, initialStage).apply { return OnboardingTestSetup(composeTestRule, isFullOnboardingEnabled, initialStage).apply {
setDefaultContent() setDefaultContent()
} }
} }
@ -30,7 +30,7 @@ class OnboardingViewTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun verify_test_setup_stage_1() { fun verify_test_setup_stage_1() {
val testSetup = newTestSetup(OnboardingStage.ShieldedByDefault) val testSetup = newTestSetup(initialStage = OnboardingStage.ShieldedByDefault)
assertEquals(OnboardingStage.ShieldedByDefault, testSetup.getOnboardingStage()) assertEquals(OnboardingStage.ShieldedByDefault, testSetup.getOnboardingStage())
assertEquals(0, testSetup.getOnImportWalletCallbackCount()) assertEquals(0, testSetup.getOnImportWalletCallbackCount())
@ -40,7 +40,7 @@ class OnboardingViewTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun verify_test_setup_stage_4() { fun verify_test_setup_stage_4() {
val testSetup = newTestSetup(OnboardingStage.Wallet) val testSetup = newTestSetup(initialStage = OnboardingStage.Wallet)
assertEquals(OnboardingStage.Wallet, testSetup.getOnboardingStage()) assertEquals(OnboardingStage.Wallet, testSetup.getOnboardingStage())
assertEquals(0, testSetup.getOnImportWalletCallbackCount()) assertEquals(0, testSetup.getOnImportWalletCallbackCount())
@ -50,7 +50,7 @@ class OnboardingViewTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun stage_1_layout() { fun stage_1_layout() {
newTestSetup(OnboardingStage.ShieldedByDefault) newTestSetup(initialStage = OnboardingStage.ShieldedByDefault)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also { composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.assertExists() it.assertExists()
@ -77,7 +77,7 @@ class OnboardingViewTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun stage_2_layout() { fun stage_2_layout() {
newTestSetup(OnboardingStage.UnifiedAddresses) newTestSetup(initialStage = OnboardingStage.UnifiedAddresses)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also { composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
it.assertExists() it.assertExists()
@ -114,7 +114,7 @@ class OnboardingViewTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun stage_3_layout() { fun stage_3_layout() {
newTestSetup(OnboardingStage.More) newTestSetup(initialStage = OnboardingStage.More)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also { composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
it.assertExists() it.assertExists()
@ -151,7 +151,7 @@ class OnboardingViewTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun stage_4_layout() { fun stage_4_layout() {
newTestSetup(OnboardingStage.Wallet) newTestSetup(initialStage = OnboardingStage.Wallet)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also { composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
it.assertDoesNotExist() it.assertDoesNotExist()
@ -180,10 +180,40 @@ class OnboardingViewTest : UiTestPrerequisites() {
} }
} }
@Test
@MediumTest
fun stage_4_layout_short_onboarding() {
newTestSetup(isFullOnboardingEnabled = false, initialStage = OnboardingStage.Wallet)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.onboarding_back)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also {
it.assertExists()
it.assertIsEnabled()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also {
it.assertExists()
it.assertIsEnabled()
it.assertHasClickAction()
}
}
@Test @Test
@MediumTest @MediumTest
fun stage_1_skip() { fun stage_1_skip() {
val testSetup = newTestSetup(OnboardingStage.ShieldedByDefault) val testSetup = newTestSetup(initialStage = OnboardingStage.ShieldedByDefault)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also { composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
it.performClick() it.performClick()
@ -195,7 +225,7 @@ class OnboardingViewTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun last_stage_click_create_wallet() { fun last_stage_click_create_wallet() {
val testSetup = newTestSetup(OnboardingStage.Wallet) val testSetup = newTestSetup(initialStage = OnboardingStage.Wallet)
val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)) val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet))
newWalletButton.performClick() newWalletButton.performClick()
@ -207,7 +237,7 @@ class OnboardingViewTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun last_stage_click_import_wallet() { fun last_stage_click_import_wallet() {
val testSetup = newTestSetup(OnboardingStage.Wallet) val testSetup = newTestSetup(initialStage = OnboardingStage.Wallet)
val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)) val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet))
newWalletButton.performClick() newWalletButton.performClick()
@ -219,7 +249,7 @@ class OnboardingViewTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun multi_stage_progression() { fun multi_stage_progression() {
val testSetup = newTestSetup(OnboardingStage.ShieldedByDefault) val testSetup = newTestSetup(initialStage = OnboardingStage.ShieldedByDefault)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also { composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.performClick() it.performClick()

View File

@ -8,7 +8,11 @@ object ConfigurationEntries {
/* /*
* Disabled because we don't have the URI parser support in the SDK yet. * Disabled because we don't have the URI parser support in the SDK yet.
*
*/ */
val IS_REQUEST_ZEC_ENABLED = BooleanConfigurationEntry(ConfigKey("is_request_zec_enabled"), false) val IS_REQUEST_ZEC_ENABLED = BooleanConfigurationEntry(ConfigKey("is_request_zec_enabled"), false)
/*
* The full onboarding flow is functional and tested, but it is disabled by default for an initially minimal feature set.
*/
val IS_FULL_ONBOARDING_ENABLED = BooleanConfigurationEntry(ConfigKey("is_full_onboarding_enabled"), false)
} }

View File

@ -18,7 +18,11 @@ import co.electriccoin.zcash.spackle.EmulatorWtfUtil
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.ui.BuildConfig import co.electriccoin.zcash.ui.BuildConfig
import co.electriccoin.zcash.ui.MainActivity import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.onboarding.model.OnboardingStage
import co.electriccoin.zcash.ui.screen.onboarding.state.OnboardingState
import co.electriccoin.zcash.ui.screen.onboarding.view.Onboarding import co.electriccoin.zcash.ui.screen.onboarding.view.Onboarding
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.restore.view.RestoreWallet import co.electriccoin.zcash.ui.screen.restore.view.RestoreWallet
@ -47,8 +51,18 @@ internal fun WrapOnboarding(
// TODO [#383]: https://github.com/zcash/secant-android-wallet/issues/383 // TODO [#383]: https://github.com/zcash/secant-android-wallet/issues/383
if (!onboardingViewModel.isImporting.collectAsStateWithLifecycle().value) { if (!onboardingViewModel.isImporting.collectAsStateWithLifecycle().value) {
val isFullOnboardingEnabled = ConfigurationEntries.IS_FULL_ONBOARDING_ENABLED.getValue(RemoteConfig.current)
val onboardingState = if (isFullOnboardingEnabled) {
onboardingViewModel.onboardingState
} else {
// Force to the last screen, which is the "create wallet" screen.
// This simplifies the implementation inside the Onboarding composable.
OnboardingState(OnboardingStage.values().last())
}
Onboarding( Onboarding(
onboardingState = onboardingViewModel.onboardingState, isFullOnboardingEnabled = isFullOnboardingEnabled,
onboardingState = onboardingState,
isDebugMenuEnabled = isDebugMenuEnabled, isDebugMenuEnabled = isDebugMenuEnabled,
onImportWallet = { onImportWallet = {
// In the case of the app currently being messed with by the robo test runner on // In the case of the app currently being messed with by the robo test runner on
@ -64,7 +78,7 @@ internal fun WrapOnboarding(
return@Onboarding return@Onboarding
} }
onboardingViewModel.isImporting.value = true onboardingViewModel.setIsImporting(true)
}, },
onCreateWallet = { onCreateWallet = {
if (FirebaseTestLabUtil.isFirebaseTestLab(applicationContext)) { if (FirebaseTestLabUtil.isFirebaseTestLab(applicationContext)) {
@ -115,7 +129,7 @@ private fun WrapRestore(activity: ComponentActivity) {
RestoreWallet( RestoreWallet(
completeWordList.list, completeWordList.list,
restoreViewModel.userWordList, restoreViewModel.userWordList,
onBack = { onboardingViewModel.isImporting.value = false }, onBack = { onboardingViewModel.setIsImporting(false) },
paste = { paste = {
val clipboardManager = applicationContext.getSystemService(ClipboardManager::class.java) val clipboardManager = applicationContext.getSystemService(ClipboardManager::class.java)
return@RestoreWallet clipboardManager?.primaryClip?.toString() return@RestoreWallet clipboardManager?.primaryClip?.toString()

View File

@ -55,8 +55,9 @@ fun ComposablePreview() {
ZcashTheme(darkTheme = true) { ZcashTheme(darkTheme = true) {
GradientSurface { GradientSurface {
Onboarding( Onboarding(
isFullOnboardingEnabled = true,
OnboardingState(OnboardingStage.Wallet), OnboardingState(OnboardingStage.Wallet),
false, isDebugMenuEnabled = false,
onImportWallet = {}, onImportWallet = {},
onCreateWallet = {}, onCreateWallet = {},
onFixtureWallet = {} onFixtureWallet = {}
@ -66,12 +67,16 @@ fun ComposablePreview() {
} }
/** /**
* @param isFullOnboardingEnabled Feature toggle to control whether the full onboarding flow is enabled. If disabled, then an abbreviated flow is shown
* and the onboarding state is treated effectively as if it is [OnboardingStage.Wallet].
* @param onImportWallet Callback when the user decides to import an existing wallet. * @param onImportWallet Callback when the user decides to import an existing wallet.
* @param onCreateWallet Callback when the user decides to create a new wallet. * @param onCreateWallet Callback when the user decides to create a new wallet.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongParameterList")
fun Onboarding( fun Onboarding(
isFullOnboardingEnabled: Boolean,
onboardingState: OnboardingState, onboardingState: OnboardingState,
isDebugMenuEnabled: Boolean, isDebugMenuEnabled: Boolean,
onImportWallet: () -> Unit, onImportWallet: () -> Unit,
@ -81,7 +86,7 @@ fun Onboarding(
val currentStage = onboardingState.current.collectAsStateWithLifecycle().value val currentStage = onboardingState.current.collectAsStateWithLifecycle().value
Scaffold( Scaffold(
topBar = { topBar = {
OnboardingTopAppBar(onboardingState, isDebugMenuEnabled, onFixtureWallet) OnboardingTopAppBar(isFullOnboardingEnabled, onboardingState, isDebugMenuEnabled, onFixtureWallet)
}, },
bottomBar = { bottomBar = {
BottomNav(currentStage, onboardingState::goNext, onCreateWallet, onImportWallet) BottomNav(currentStage, onboardingState::goNext, onCreateWallet, onImportWallet)
@ -97,6 +102,7 @@ fun Onboarding(
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
private fun OnboardingTopAppBar( private fun OnboardingTopAppBar(
isFullOnboardingEnabled: Boolean,
onboardingState: OnboardingState, onboardingState: OnboardingState,
isDebugMenuEnabled: Boolean, isDebugMenuEnabled: Boolean,
onFixtureWallet: () -> Unit onFixtureWallet: () -> Unit
@ -106,6 +112,7 @@ private fun OnboardingTopAppBar(
TopAppBar( TopAppBar(
title = { Text(text = stringResource(id = R.string.app_name)) }, title = { Text(text = stringResource(id = R.string.app_name)) },
navigationIcon = { navigationIcon = {
if (isFullOnboardingEnabled) {
if (currentStage.hasPrevious()) { if (currentStage.hasPrevious()) {
IconButton(onboardingState::goPrevious) { IconButton(onboardingState::goPrevious) {
Icon( Icon(
@ -114,6 +121,7 @@ private fun OnboardingTopAppBar(
) )
} }
} }
}
}, },
actions = { actions = {
if (currentStage.hasNext()) { if (currentStage.hasNext()) {
@ -145,11 +153,6 @@ private fun DebugMenu(onFixtureWallet: () -> Unit) {
} }
} }
/**
* @param onImportWallet Callback when the user decides to import an existing wallet.
* @param onCreateWallet Callback when the user decides to create a new wallet.
*/
@Composable @Composable
fun OnboardingMainContent( fun OnboardingMainContent(
paddingValues: PaddingValues, paddingValues: PaddingValues,

View File

@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.ext.collectWith import cash.z.ecc.android.sdk.ext.collectWith
import co.electriccoin.zcash.ui.screen.onboarding.model.OnboardingStage import co.electriccoin.zcash.ui.screen.onboarding.model.OnboardingStage
import co.electriccoin.zcash.ui.screen.onboarding.state.OnboardingState import co.electriccoin.zcash.ui.screen.onboarding.state.OnboardingState
import kotlinx.coroutines.flow.MutableStateFlow
/* /*
* Android-specific ViewModel. This is used to save and restore state across Activity recreations * Android-specific ViewModel. This is used to save and restore state across Activity recreations
@ -15,7 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
*/ */
class OnboardingViewModel( class OnboardingViewModel(
application: Application, application: Application,
savedStateHandle: SavedStateHandle private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
val onboardingState: OnboardingState = run { val onboardingState: OnboardingState = run {
@ -35,21 +34,17 @@ class OnboardingViewModel(
// This is a bit weird being placed here, but onboarding currently is considered complete when // This is a bit weird being placed here, but onboarding currently is considered complete when
// the user has a persisted wallet. Also import allows the user to go back to onboarding, while // the user has a persisted wallet. Also import allows the user to go back to onboarding, while
// creating a new wallet does not. // creating a new wallet does not.
val isImporting = run { val isImporting = savedStateHandle.getStateFlow(KEY_IS_IMPORTING, false)
val initialValue = savedStateHandle.get<Boolean?>(KEY_IS_IMPORTING) ?: false
MutableStateFlow(initialValue) fun setIsImporting(isImporting: Boolean) {
savedStateHandle[KEY_IS_IMPORTING] = isImporting
} }
init { init {
// viewModelScope is constructed with Dispatchers.Main.immediate, so this will // viewModelScope is constructed with Dispatchers.Main.immediate, so this will
// update the save state as soon as a change occurs. // update the save state as soon as a change occurs.
onboardingState.current.collectWith(viewModelScope) { onboardingState.current.collectWith(viewModelScope) {
savedStateHandle.set(KEY_STAGE, it) savedStateHandle[KEY_STAGE] = it
}
isImporting.collectWith(viewModelScope) {
savedStateHandle.set(KEY_IS_IMPORTING, it)
} }
} }

View File

@ -79,8 +79,6 @@ class ScreenshotTest : UiTestPrerequisites() {
.captureToBitmap() .captureToBitmap()
.writeToTestStorage("$screenshotName - $tag") .writeToTestStorage("$screenshotName - $tag")
} }
private val emptyConfiguration = StringConfiguration(persistentMapOf(), null)
} }
@get:Rule @get:Rule
@ -146,6 +144,7 @@ class ScreenshotTest : UiTestPrerequisites() {
composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.secretState.value is SecretState.None } composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.secretState.value is SecretState.None }
if (ConfigurationEntries.IS_FULL_ONBOARDING_ENABLED.getValue(emptyConfiguration)) {
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_1_header)).also { composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_1_header)).also {
it.assertExists() it.assertExists()
} }
@ -154,6 +153,7 @@ class ScreenshotTest : UiTestPrerequisites() {
it.assertExists() it.assertExists()
it.performClick() it.performClick()
} }
}
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_4_import_existing_wallet)).also { composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_4_import_existing_wallet)).also {
it.assertExists() it.assertExists()
@ -333,9 +333,11 @@ class ScreenshotTest : UiTestPrerequisites() {
} }
} }
private val emptyConfiguration = StringConfiguration(persistentMapOf(), null)
private fun onboardingScreenshots(resContext: Context, tag: String, composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>) { private fun onboardingScreenshots(resContext: Context, tag: String, composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>) {
composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.secretState.value is SecretState.None } composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.secretState.value is SecretState.None }
if (ConfigurationEntries.IS_FULL_ONBOARDING_ENABLED.getValue(emptyConfiguration)) {
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_1_header)).also { composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_1_header)).also {
it.assertExists() it.assertExists()
} }
@ -360,6 +362,7 @@ private fun onboardingScreenshots(resContext: Context, tag: String, composeTestR
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_next)).also { composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_next)).also {
it.performClick() it.performClick()
} }
}
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_4_header)).also { composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_4_header)).also {
it.assertExists() it.assertExists()