From 543d5f2b59b075d65dd8d03f598300cc9a32dfe9 Mon Sep 17 00:00:00 2001 From: Honza Rychnovsky Date: Mon, 27 Mar 2023 08:25:36 +0200 Subject: [PATCH] [#792] Add Sending Screens * [#804][Design system] Paddings - Dimens - Initial commit to ensure it meets our requirements - This solution is now in parallel with a simpler existing Paddings solution - Four spacing groups defined - Provides a way to define default and custom spacing values - Distinguished by screen size, but other metrics also available (e.g. orientation, aspect ratio, layout direction or screen shape) * Fix spacing value * Move spacings change logic to comment - We've decided to have only one regular spacing group for now, which is suitable for most of current phone devices * Move Dimens out of internal package * Link TODO for later Paddings remove * Link issue of Use Dimens across the app to TODO * [#792] Add sending screens - This extends the Send screen logic with several subscreens - A transaction submission connected to the Synchronizer - Some logic moved to Android specific class - System back navigation handler implemented - Styling with dimes introduced to all Send screens - New UI, Android specific, integration and screenshot tests added - Additionally we could consider implementing a proper SendState mechanism, as we do in e.g. BackupState * File issue of problematic pseudolocales texts inserted in Send.Form screen * Add scrolling to send view * Switch SendViewIntegrationTest away from TestSetup * Provide MockSynchronizer to integration test --------- Co-authored-by: Carter Jernigan --- .../cash/z/ecc/sdk/fixture/ZecSendFixture.kt | 4 +- .../zcash/ui/fixture/MockSynchronizer.kt | 168 +++++++++ .../screen/send/ComposeContentTestRuleExt.kt | 118 ++++++ .../zcash/ui/screen/send/SendViewTestSetup.kt | 103 ++++++ .../zcash/ui/screen/send/ext/MemoExtTest.kt | 31 ++ .../integration/SendViewIntegrationTest.kt | 90 +++++ .../screen/send/view/SendViewAndroidTest.kt | 95 +++++ .../zcash/ui/screen/send/view/SendViewTest.kt | 268 +++++++------- .../zcash/ui/screen/scan/AndroidScan.kt | 2 - .../zcash/ui/screen/send/AndroidSend.kt | 74 +++- .../zcash/ui/screen/send/ext/MemoExt.kt | 20 ++ .../ui/screen/send/ext/WalletAddressExt.kt | 2 +- .../zcash/ui/screen/send/model/SendStage.kt | 5 +- .../zcash/ui/screen/send/view/SendView.kt | 337 ++++++++++++++---- .../src/main/res/ui/common/values/strings.xml | 1 + .../src/main/res/ui/send/values/strings.xml | 20 +- .../zcash/ui/screenshot/ScreenshotTest.kt | 39 +- 17 files changed, 1155 insertions(+), 222 deletions(-) create mode 100644 ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/fixture/MockSynchronizer.kt create mode 100644 ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ComposeContentTestRuleExt.kt create mode 100644 ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/SendViewTestSetup.kt create mode 100644 ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ext/MemoExtTest.kt create mode 100644 ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/integration/SendViewIntegrationTest.kt create mode 100644 ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/view/SendViewAndroidTest.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/ext/MemoExt.kt diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/ZecSendFixture.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/ZecSendFixture.kt index 845d80f6..f49e7dad 100644 --- a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/ZecSendFixture.kt +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/fixture/ZecSendFixture.kt @@ -16,6 +16,6 @@ object ZecSendFixture { suspend fun new( address: String = ADDRESS, amount: Zatoshi = AMOUNT, - message: Memo = MEMO - ) = ZecSend(WalletAddress.Unified.new(address), amount, message) + memo: Memo = MEMO + ) = ZecSend(WalletAddress.Unified.new(address), amount, memo) } diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/fixture/MockSynchronizer.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/fixture/MockSynchronizer.kt new file mode 100644 index 00000000..85214157 --- /dev/null +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/fixture/MockSynchronizer.kt @@ -0,0 +1,168 @@ +package co.electriccoin.zcash.ui.fixture + +import cash.z.ecc.android.sdk.CloseableSynchronizer +import cash.z.ecc.android.sdk.Synchronizer +import cash.z.ecc.android.sdk.block.CompactBlockProcessor +import cash.z.ecc.android.sdk.model.Account +import cash.z.ecc.android.sdk.model.BlockHeight +import cash.z.ecc.android.sdk.model.PendingTransaction +import cash.z.ecc.android.sdk.model.Transaction +import cash.z.ecc.android.sdk.model.TransactionOverview +import cash.z.ecc.android.sdk.model.TransactionRecipient +import cash.z.ecc.android.sdk.model.UnifiedSpendingKey +import cash.z.ecc.android.sdk.model.WalletBalance +import cash.z.ecc.android.sdk.model.Zatoshi +import cash.z.ecc.android.sdk.model.ZcashNetwork +import cash.z.ecc.android.sdk.type.AddressType +import cash.z.ecc.android.sdk.type.ConsensusMatchType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow + +/** + * Mocked Synchronizer that can be used instead of the production SdkSynchronizer e.g. for tests. + */ +@Suppress("TooManyFunctions", "UNUSED_PARAMETER") +internal class MockSynchronizer : CloseableSynchronizer { + + override val clearedTransactions: Flow> + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val latestBirthdayHeight: BlockHeight + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val latestHeight: BlockHeight + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val network: ZcashNetwork + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val networkHeight: StateFlow + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override var onChainErrorHandler: ((BlockHeight, BlockHeight) -> Any)? + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + set(value) {} + + override var onCriticalErrorHandler: ((Throwable?) -> Boolean)? + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + set(value) {} + + override var onProcessorErrorHandler: ((Throwable?) -> Boolean)? + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + set(value) {} + + override var onSetupErrorHandler: ((Throwable?) -> Boolean)? + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + set(value) {} + + override var onSubmissionErrorHandler: ((Throwable?) -> Boolean)? + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + set(value) {} + + override val orchardBalances: StateFlow + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val pendingTransactions: Flow> + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val processorInfo: Flow + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val progress: Flow + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val receivedTransactions: Flow> + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val saplingBalances: StateFlow + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val sentTransactions: Flow> + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val status: Flow + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override val transparentBalances: StateFlow + get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + + override fun close() { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override fun getMemos(transactionOverview: TransactionOverview): Flow { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun getNearestRewindHeight(height: BlockHeight): BlockHeight { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override fun getRecipients(transactionOverview: TransactionOverview): Flow { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun getSaplingAddress(account: Account): String { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun getTransparentAddress(account: Account): String { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun getTransparentBalance(tAddr: String): WalletBalance { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun getUnifiedAddress(account: Account): String { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun isValidShieldedAddr(address: String): Boolean { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun isValidTransparentAddr(address: String): Boolean { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun isValidUnifiedAddr(address: String): Boolean { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun quickRewind() { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun refreshUtxos(account: Account, since: BlockHeight): Int? { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun rewindToNearestHeight(height: BlockHeight, alsoClearBlockCache: Boolean) { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + /** + * This method intentionally returns empty flow, as the PendingTransaction is only an SDK internal class. + */ + override fun sendToAddress(usk: UnifiedSpendingKey, amount: Zatoshi, toAddress: String, memo: String): Flow { + return emptyFlow() + } + + override fun shieldFunds(usk: UnifiedSpendingKey, memo: String): Flow { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun validateAddress(address: String): AddressType { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + override suspend fun validateConsensusBranch(): ConsensusMatchType { + error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.") + } + + companion object { + fun new() = MockSynchronizer() + } +} diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ComposeContentTestRuleExt.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ComposeContentTestRuleExt.kt new file mode 100644 index 00000000..3ff867c6 --- /dev/null +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ComposeContentTestRuleExt.kt @@ -0,0 +1,118 @@ +package co.electriccoin.zcash.ui.screen.send + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import cash.z.ecc.android.sdk.fixture.WalletAddressFixture +import cash.z.ecc.android.sdk.model.MonetarySeparators +import cash.z.ecc.sdk.fixture.MemoFixture +import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.test.getStringResource + +internal fun ComposeContentTestRule.clickBack() { + onNodeWithContentDescription(getStringResource(R.string.send_back_content_description)).also { + it.performClick() + } +} + +internal fun ComposeContentTestRule.setValidAmount() { + onNodeWithText(getStringResource(R.string.send_amount)).also { + val separators = MonetarySeparators.current() + it.performTextClearance() + it.performTextInput("123${separators.decimal}456") + } +} + +internal fun ComposeContentTestRule.setAmount(amount: String) { + onNodeWithText(getStringResource(R.string.send_amount)).also { + it.performTextClearance() + it.performTextInput(amount) + } +} + +internal fun ComposeContentTestRule.setValidAddress() { + onNodeWithText(getStringResource(R.string.send_to)).also { + it.performTextClearance() + it.performTextInput(WalletAddressFixture.UNIFIED_ADDRESS_STRING) + } +} + +internal fun ComposeContentTestRule.setAddress(address: String) { + onNodeWithText(getStringResource(R.string.send_to)).also { + it.performTextClearance() + it.performTextInput(address) + } +} + +internal fun ComposeContentTestRule.setValidMemo() { + onNodeWithText(getStringResource(R.string.send_memo)).also { + it.performTextClearance() + it.performTextInput(MemoFixture.MEMO_STRING) + } +} + +internal fun ComposeContentTestRule.setMemo(memo: String) { + onNodeWithText(getStringResource(R.string.send_memo)).also { + it.performTextClearance() + it.performTextInput(memo) + } +} + +internal fun ComposeContentTestRule.clickCreateAndSend() { + onNodeWithText(getStringResource(R.string.send_create)).also { + it.performClick() + } +} + +internal fun ComposeContentTestRule.clickConfirmation() { + onNodeWithText(getStringResource(R.string.send_confirmation_button)).also { + it.performClick() + } +} + +internal fun ComposeContentTestRule.assertOnForm() { + onNodeWithText(getStringResource(R.string.send_create)).also { + it.assertExists() + } +} + +internal fun ComposeContentTestRule.assertOnConfirmation() { + onNodeWithText(getStringResource(R.string.send_confirmation_button)).also { + it.assertExists() + } +} + +internal fun ComposeContentTestRule.assertOnSending() { + onNodeWithText(getStringResource(R.string.send_in_progress_wait)).also { + it.assertExists() + } +} + +internal fun ComposeContentTestRule.assertOnSendSuccessful() { + onNodeWithText(getStringResource(R.string.send_successful_title)).also { + it.assertExists() + } +} + +internal fun ComposeContentTestRule.assertOnSendFailure() { + onNodeWithText(getStringResource(R.string.send_failure_title)).also { + it.assertExists() + } +} + +internal fun ComposeContentTestRule.assertSendEnabled() { + onNodeWithText(getStringResource(R.string.send_create)).also { + it.assertIsEnabled() + } +} + +internal fun ComposeContentTestRule.assertSendDisabled() { + onNodeWithText(getStringResource(R.string.send_create)).also { + it.assertIsNotEnabled() + } +} diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/SendViewTestSetup.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/SendViewTestSetup.kt new file mode 100644 index 00000000..89374da2 --- /dev/null +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/SendViewTestSetup.kt @@ -0,0 +1,103 @@ +package co.electriccoin.zcash.ui.screen.send + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import cash.z.ecc.android.sdk.model.ZecSend +import cash.z.ecc.sdk.fixture.ZatoshiFixture +import co.electriccoin.zcash.ui.design.theme.ZcashTheme +import co.electriccoin.zcash.ui.screen.send.ext.Saver +import co.electriccoin.zcash.ui.screen.send.model.SendStage +import co.electriccoin.zcash.ui.screen.send.view.Send +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.atomic.AtomicInteger + +class SendViewTestSetup( + private val composeTestRule: ComposeContentTestRule, + private val initialState: SendStage, + private val initialZecSend: ZecSend? +) { + private val onBackCount = AtomicInteger(0) + private val onCreateCount = AtomicInteger(0) + val mutableActionExecuted = MutableStateFlow(false) + + @Volatile + private var lastSendStage: SendStage? = null + + @Volatile + private var lastZecSend: ZecSend? = null + + fun getOnBackCount(): Int { + composeTestRule.waitForIdle() + return onBackCount.get() + } + + fun getOnCreateCount(): Int { + composeTestRule.waitForIdle() + return onCreateCount.get() + } + + fun getLastZecSend(): ZecSend? { + composeTestRule.waitForIdle() + return lastZecSend + } + + fun getLastSendStage(): SendStage? { + composeTestRule.waitForIdle() + return lastSendStage + } + + @Composable + @Suppress("TestFunctionName") + fun DefaultContent() { + val (sendStage, setSendStage) = + rememberSaveable { mutableStateOf(initialState) } + + lastSendStage = sendStage + + val onBackAction = { + onBackCount.incrementAndGet() + when (sendStage) { + SendStage.Form -> {} + SendStage.Confirmation -> setSendStage(SendStage.Form) + SendStage.Sending -> {} + SendStage.SendFailure -> setSendStage(SendStage.Form) + SendStage.SendSuccessful -> {} + } + } + + BackHandler { + onBackAction() + } + + val (zecSend, setZecSend) = + rememberSaveable(stateSaver = ZecSend.Saver) { mutableStateOf(initialZecSend) } + + lastZecSend = zecSend + + ZcashTheme { + Send( + mySpendableBalance = ZatoshiFixture.new(), + sendStage = sendStage, + onSendStageChange = setSendStage, + zecSend = zecSend, + onZecSendChange = setZecSend, + onBack = onBackAction, + onCreateAndSend = { + onCreateCount.incrementAndGet() + lastZecSend = it + mutableActionExecuted.update { true } + } + ) + } + } + + fun setDefaultContent() { + composeTestRule.setContent { + DefaultContent() + } + } +} diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ext/MemoExtTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ext/MemoExtTest.kt new file mode 100644 index 00000000..5cd30d19 --- /dev/null +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ext/MemoExtTest.kt @@ -0,0 +1,31 @@ +package co.electriccoin.zcash.ui.screen.send.ext + +import androidx.test.filters.SmallTest +import cash.z.ecc.sdk.fixture.MemoFixture +import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.test.getAppContext +import org.junit.Test +import kotlin.test.assertEquals + +class MemoExtTest { + + @Test + @SmallTest + fun value_or_empty_char_test_empty_input() { + val actual = MemoFixture.new(memoString = "").valueOrEmptyChar(getAppContext()) + + val expected = getAppContext().getString(R.string.empty_char) + + assertEquals(expected, actual) + } + + @Test + @SmallTest + fun value_or_empty_char_test_non_empty_input() { + val actual = MemoFixture.new(memoString = MemoFixture.MEMO_STRING).valueOrEmptyChar(getAppContext()) + + val expected = MemoFixture.MEMO_STRING + + assertEquals(expected, actual) + } +} diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/integration/SendViewIntegrationTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/integration/SendViewIntegrationTest.kt new file mode 100644 index 00000000..6c3daaf9 --- /dev/null +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/integration/SendViewIntegrationTest.kt @@ -0,0 +1,90 @@ +package co.electriccoin.zcash.ui.screen.send.integration + +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.filters.MediumTest +import cash.z.ecc.android.sdk.fixture.WalletFixture +import cash.z.ecc.android.sdk.model.ZcashNetwork +import cash.z.ecc.sdk.fixture.ZatoshiFixture +import cash.z.ecc.sdk.fixture.ZecSendFixture +import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.fixture.MockSynchronizer +import co.electriccoin.zcash.ui.screen.send.WrapSend +import co.electriccoin.zcash.ui.screen.send.assertOnConfirmation +import co.electriccoin.zcash.ui.screen.send.assertOnForm +import co.electriccoin.zcash.ui.screen.send.clickBack +import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend +import co.electriccoin.zcash.ui.screen.send.setAddress +import co.electriccoin.zcash.ui.screen.send.setAmount +import co.electriccoin.zcash.ui.screen.send.setMemo +import co.electriccoin.zcash.ui.test.getStringResource +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test + +class SendViewIntegrationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val wallet = WalletFixture.Alice + private val network = ZcashNetwork.Testnet + private val spendingKey = runBlocking { + WalletFixture.Alice.getUnifiedSpendingKey( + seed = wallet.seedPhrase, + network = network + ) + } + private val synchronizer = MockSynchronizer.new() + private val balance = ZatoshiFixture.new() + + @Test + @MediumTest + fun send_screens_values_state_restoration() { + val restorationTester = StateRestorationTester(composeTestRule) + + val expectedAmount = ZecSendFixture.AMOUNT.value + val expectedAddress = ZecSendFixture.ADDRESS + val expectedMemo = ZecSendFixture.MEMO.value + + restorationTester.setContent { + WrapSend( + synchronizer = synchronizer, + spendableBalance = balance, + spendingKey = spendingKey, + goBack = {} + ) + } + + // Fill form + composeTestRule.assertOnForm() + composeTestRule.setAddress(expectedAddress) + composeTestRule.setAmount(expectedAmount.toString()) + composeTestRule.setMemo(expectedMemo) + + // Move to confirmation + composeTestRule.clickCreateAndSend() + composeTestRule.assertOnConfirmation() + + restorationTester.emulateSavedInstanceStateRestore() + + // Check if stage recreated correctly + composeTestRule.assertOnConfirmation() + + // Move back to form + composeTestRule.clickBack() + composeTestRule.assertOnForm() + + // And check recreated form values too + // We use that the assertTextContains searches in SemanticsProperties.EditableText too + // Note also that we don't check the amount field value, as it's changed by validation mechanisms + composeTestRule.onNodeWithText(getStringResource(R.string.send_to)).also { + it.assertTextContains(ZecSendFixture.ADDRESS) + } + composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also { + it.assertTextContains(ZecSendFixture.MEMO.value) + } + } +} diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/view/SendViewAndroidTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/view/SendViewAndroidTest.kt new file mode 100644 index 00000000..6d27cd39 --- /dev/null +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/view/SendViewAndroidTest.kt @@ -0,0 +1,95 @@ +package co.electriccoin.zcash.ui.screen.send.view + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.espresso.Espresso +import androidx.test.filters.MediumTest +import cash.z.ecc.android.sdk.model.ZecSend +import cash.z.ecc.sdk.fixture.ZecSendFixture +import co.electriccoin.zcash.test.UiTestPrerequisites +import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup +import co.electriccoin.zcash.ui.screen.send.assertOnConfirmation +import co.electriccoin.zcash.ui.screen.send.assertOnForm +import co.electriccoin.zcash.ui.screen.send.assertOnSendFailure +import co.electriccoin.zcash.ui.screen.send.assertOnSendSuccessful +import co.electriccoin.zcash.ui.screen.send.assertOnSending +import co.electriccoin.zcash.ui.screen.send.clickConfirmation +import co.electriccoin.zcash.ui.screen.send.model.SendStage +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +// Non-multiplatform tests that require interacting with the Android system (e.g. system back navigation) +// These don't have persistent state, so they are still unit tests. +class SendViewAndroidTest : UiTestPrerequisites() { + @get:Rule + val composeTestRule = createComposeRule() + + private fun newTestSetup( + sendStage: SendStage = SendStage.Form, + zecSend: ZecSend? = null + ) = SendViewTestSetup( + composeTestRule, + sendStage, + zecSend + ).apply { + setDefaultContent() + } + + @Test + @MediumTest + fun back_on_sending_with_system_navigation_disabled_check() { + val testSetup = newTestSetup( + SendStage.Confirmation, + runBlocking { ZecSendFixture.new() } + ) + + assertEquals(0, testSetup.getOnBackCount()) + + composeTestRule.assertOnConfirmation() + composeTestRule.clickConfirmation() + composeTestRule.assertOnSending() + + Espresso.pressBack() + + composeTestRule.assertOnSending() + + assertEquals(1, testSetup.getOnBackCount()) + } + + @Test + @MediumTest + fun back_on_send_successful_with_system_navigation() { + val testSetup = newTestSetup( + SendStage.SendSuccessful, + runBlocking { ZecSendFixture.new() } + ) + + assertEquals(0, testSetup.getOnBackCount()) + + composeTestRule.assertOnSendSuccessful() + + Espresso.pressBack() + + assertEquals(1, testSetup.getOnBackCount()) + } + + @Test + @MediumTest + fun back_on_send_failure_with_system_navigation() { + val testSetup = newTestSetup( + SendStage.SendFailure, + runBlocking { ZecSendFixture.new() } + ) + + assertEquals(0, testSetup.getOnBackCount()) + + composeTestRule.assertOnSendFailure() + + Espresso.pressBack() + + composeTestRule.assertOnForm() + + assertEquals(1, testSetup.getOnBackCount()) + } +} diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/view/SendViewTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/view/SendViewTest.kt index f2e3e3a6..46b24f78 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/view/SendViewTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/view/SendViewTest.kt @@ -1,14 +1,10 @@ package co.electriccoin.zcash.ui.screen.send.view -import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextClearance -import androidx.compose.ui.test.performTextInput import androidx.test.filters.MediumTest import cash.z.ecc.android.sdk.ext.collectWith import cash.z.ecc.android.sdk.fixture.WalletAddressFixture @@ -16,22 +12,35 @@ import cash.z.ecc.android.sdk.model.Memo import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.ZecSend -import cash.z.ecc.sdk.fixture.MemoFixture -import cash.z.ecc.sdk.fixture.ZatoshiFixture import cash.z.ecc.sdk.fixture.ZecRequestFixture +import cash.z.ecc.sdk.fixture.ZecSendFixture import co.electriccoin.zcash.test.UiTestPrerequisites import co.electriccoin.zcash.ui.R -import co.electriccoin.zcash.ui.design.theme.ZcashTheme +import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup +import co.electriccoin.zcash.ui.screen.send.assertOnConfirmation +import co.electriccoin.zcash.ui.screen.send.assertOnForm +import co.electriccoin.zcash.ui.screen.send.assertOnSendFailure +import co.electriccoin.zcash.ui.screen.send.assertOnSendSuccessful +import co.electriccoin.zcash.ui.screen.send.assertOnSending +import co.electriccoin.zcash.ui.screen.send.assertSendDisabled +import co.electriccoin.zcash.ui.screen.send.assertSendEnabled +import co.electriccoin.zcash.ui.screen.send.clickBack +import co.electriccoin.zcash.ui.screen.send.clickConfirmation +import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend +import co.electriccoin.zcash.ui.screen.send.model.SendStage +import co.electriccoin.zcash.ui.screen.send.setAmount +import co.electriccoin.zcash.ui.screen.send.setMemo +import co.electriccoin.zcash.ui.screen.send.setValidAddress +import co.electriccoin.zcash.ui.screen.send.setValidAmount +import co.electriccoin.zcash.ui.screen.send.setValidMemo import co.electriccoin.zcash.ui.test.getStringResource import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -import java.util.concurrent.atomic.AtomicInteger import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -40,11 +49,22 @@ class SendViewTest : UiTestPrerequisites() { @get:Rule val composeTestRule = createComposeRule() + private fun newTestSetup( + sendStage: SendStage = SendStage.Form, + zecSend: ZecSend? = null + ) = SendViewTestSetup( + composeTestRule, + sendStage, + zecSend + ).apply { + setDefaultContent() + } + @Test @MediumTest fun create_button_disabled() { @Suppress("UNUSED_VARIABLE") - val testSetup = TestSetup(composeTestRule) + val testSetup = newTestSetup() composeTestRule.onNodeWithText(getStringResource(R.string.send_create)).also { it.assertExists() @@ -56,10 +76,10 @@ class SendViewTest : UiTestPrerequisites() { @MediumTest @OptIn(ExperimentalCoroutinesApi::class) fun create_request_no_memo() = runTest { - val testSetup = TestSetup(composeTestRule) + val testSetup = newTestSetup() assertEquals(0, testSetup.getOnCreateCount()) - assertEquals(null, testSetup.getLastSend()) + assertEquals(null, testSetup.getLastZecSend()) composeTestRule.setValidAmount() composeTestRule.setValidAddress() @@ -74,7 +94,7 @@ class SendViewTest : UiTestPrerequisites() { assertEquals(1, testSetup.getOnCreateCount()) launch { - testSetup.getLastSend().also { + testSetup.getLastZecSend().also { assertNotNull(it) assertEquals(WalletAddressFixture.unified(), it.destination) assertEquals(Zatoshi(12345678900000), it.amount) @@ -90,10 +110,10 @@ class SendViewTest : UiTestPrerequisites() { @MediumTest @OptIn(ExperimentalCoroutinesApi::class) fun create_request_with_memo() = runTest { - val testSetup = TestSetup(composeTestRule) + val testSetup = newTestSetup() assertEquals(0, testSetup.getOnCreateCount()) - assertEquals(null, testSetup.getLastSend()) + assertEquals(null, testSetup.getLastZecSend()) composeTestRule.setValidAmount() composeTestRule.setValidAddress() @@ -110,7 +130,7 @@ class SendViewTest : UiTestPrerequisites() { assertEquals(1, testSetup.getOnCreateCount()) launch { - testSetup.getLastSend().also { + testSetup.getLastZecSend().also { assertNotNull(it) assertEquals(WalletAddressFixture.unified(), it.destination) assertEquals(Zatoshi(12345678900000), it.amount) @@ -126,11 +146,11 @@ class SendViewTest : UiTestPrerequisites() { @MediumTest @OptIn(ExperimentalCoroutinesApi::class) fun check_regex_functionality_valid_inputs() = runTest { - val testSetup = TestSetup(composeTestRule) + val testSetup = newTestSetup() val separators = MonetarySeparators.current() assertEquals(0, testSetup.getOnCreateCount()) - assertEquals(null, testSetup.getLastSend()) + assertEquals(null, testSetup.getLastZecSend()) composeTestRule.assertSendDisabled() composeTestRule.setValidAmount() @@ -168,7 +188,7 @@ class SendViewTest : UiTestPrerequisites() { assertEquals(1, testSetup.getOnCreateCount()) launch { - testSetup.getLastSend().also { + testSetup.getLastZecSend().also { assertNotNull(it) assertEquals(WalletAddressFixture.unified(), it.destination) assertEquals(Zatoshi(12345678900000), it.amount) @@ -184,11 +204,11 @@ class SendViewTest : UiTestPrerequisites() { @MediumTest @OptIn(ExperimentalCoroutinesApi::class) fun check_regex_functionality_invalid_inputs() = runTest { - val testSetup = TestSetup(composeTestRule) + val testSetup = newTestSetup() val separators = MonetarySeparators.current() assertEquals(0, testSetup.getOnCreateCount()) - assertEquals(null, testSetup.getLastSend()) + assertEquals(null, testSetup.getLastZecSend()) composeTestRule.assertSendDisabled() composeTestRule.setAmount("aaa") @@ -214,7 +234,7 @@ class SendViewTest : UiTestPrerequisites() { composeTestRule.assertSendDisabled() assertEquals(0, testSetup.getOnCreateCount()) - assertEquals(null, testSetup.getLastSend()) + assertEquals(null, testSetup.getLastZecSend()) composeTestRule.assertSendDisabled() } @@ -222,7 +242,7 @@ class SendViewTest : UiTestPrerequisites() { @MediumTest @OptIn(ExperimentalCoroutinesApi::class) fun max_memo_length() = runTest { - val testSetup = TestSetup(composeTestRule) + val testSetup = newTestSetup() composeTestRule.setValidAmount() composeTestRule.setValidAddress() @@ -246,7 +266,7 @@ class SendViewTest : UiTestPrerequisites() { assertEquals(1, testSetup.getOnCreateCount()) launch { - testSetup.getLastSend().also { + testSetup.getLastZecSend().also { assertNotNull(it) assertEquals(WalletAddressFixture.unified(), it.destination) assertEquals(Zatoshi(12345600000), it.amount) @@ -261,7 +281,7 @@ class SendViewTest : UiTestPrerequisites() { @Test @MediumTest fun back_on_form() { - val testSetup = TestSetup(composeTestRule) + val testSetup = newTestSetup() assertEquals(0, testSetup.getOnBackCount()) @@ -273,7 +293,7 @@ class SendViewTest : UiTestPrerequisites() { @Test @MediumTest fun back_on_confirmation() { - val testSetup = TestSetup(composeTestRule) + val testSetup = newTestSetup() assertEquals(0, testSetup.getOnBackCount()) @@ -284,127 +304,95 @@ class SendViewTest : UiTestPrerequisites() { composeTestRule.clickBack() composeTestRule.assertOnForm() + assertEquals(1, testSetup.getOnBackCount()) + } + + @Test + @MediumTest + fun back_on_sending_disabled_check() { + newTestSetup( + SendStage.Confirmation, + runBlocking { ZecSendFixture.new() } + ) + + composeTestRule.assertOnConfirmation() + composeTestRule.clickConfirmation() + composeTestRule.assertOnSending() + + composeTestRule.onNodeWithContentDescription(getStringResource(R.string.send_back_content_description)).also { + it.assertDoesNotExist() + } + } + + @Test + @MediumTest + fun back_on_send_successful() { + val testSetup = newTestSetup( + SendStage.SendSuccessful, + runBlocking { ZecSendFixture.new() } + ) + assertEquals(0, testSetup.getOnBackCount()) + + composeTestRule.assertOnSendSuccessful() + composeTestRule.clickBack() + + assertEquals(1, testSetup.getOnBackCount()) } - private class TestSetup(private val composeTestRule: ComposeContentTestRule) { + @Test + @MediumTest + fun close_on_send_successful() { + val testSetup = newTestSetup( + SendStage.SendSuccessful, + runBlocking { ZecSendFixture.new() } + ) - private val onBackCount = AtomicInteger(0) - private val onCreateCount = AtomicInteger(0) - val mutableActionExecuted = MutableStateFlow(false) + assertEquals(0, testSetup.getOnBackCount()) - @Volatile - private var onSendZecRequest: ZecSend? = null - - fun getOnBackCount(): Int { - composeTestRule.waitForIdle() - return onBackCount.get() + composeTestRule.assertOnSendSuccessful() + composeTestRule.onNodeWithText(getStringResource(R.string.send_successful_button)).also { + it.assertExists() + it.performClick() } - fun getOnCreateCount(): Int { - composeTestRule.waitForIdle() - return onCreateCount.get() + assertEquals(1, testSetup.getOnBackCount()) + } + + @Test + @MediumTest + fun back_on_send_failure() { + val testSetup = newTestSetup( + SendStage.SendFailure, + runBlocking { ZecSendFixture.new() } + ) + + assertEquals(0, testSetup.getOnBackCount()) + + composeTestRule.assertOnSendFailure() + composeTestRule.clickBack() + composeTestRule.assertOnForm() + + assertEquals(1, testSetup.getOnBackCount()) + } + + @Test + @MediumTest + fun close_on_send_failure() { + val testSetup = newTestSetup( + SendStage.SendFailure, + runBlocking { ZecSendFixture.new() } + ) + + assertEquals(0, testSetup.getOnBackCount()) + + composeTestRule.assertOnSendFailure() + composeTestRule.onNodeWithText(getStringResource(R.string.send_failure_button)).also { + it.assertExists() + it.performClick() } + composeTestRule.assertOnForm() - fun getLastSend(): ZecSend? { - composeTestRule.waitForIdle() - return onSendZecRequest - } - - init { - composeTestRule.setContent { - ZcashTheme { - Send( - mySpendableBalance = ZatoshiFixture.new(), - goBack = { - onBackCount.incrementAndGet() - }, - onCreateAndSend = { - onCreateCount.incrementAndGet() - onSendZecRequest = it - mutableActionExecuted.update { true } - } - ) - } - } - } - } -} - -private fun ComposeContentTestRule.clickBack() { - onNodeWithContentDescription(getStringResource(R.string.send_back_content_description)).also { - it.performClick() - } -} - -private fun ComposeContentTestRule.setValidAmount() { - onNodeWithText(getStringResource(R.string.send_amount)).also { - val separators = MonetarySeparators.current() - it.performTextClearance() - it.performTextInput("123${separators.decimal}456") - } -} - -private fun ComposeContentTestRule.setAmount(amount: String) { - onNodeWithText(getStringResource(R.string.send_amount)).also { - it.performTextClearance() - it.performTextInput(amount) - } -} - -private fun ComposeContentTestRule.setValidAddress() { - onNodeWithText(getStringResource(R.string.send_to)).also { - it.performTextClearance() - it.performTextInput(WalletAddressFixture.UNIFIED_ADDRESS_STRING) - } -} - -private fun ComposeContentTestRule.setValidMemo() { - onNodeWithText(getStringResource(R.string.send_memo)).also { - it.performTextClearance() - it.performTextInput(MemoFixture.MEMO_STRING) - } -} - -private fun ComposeContentTestRule.setMemo(memo: String) { - onNodeWithText(getStringResource(R.string.send_memo)).also { - it.performTextClearance() - it.performTextInput(memo) - } -} - -private fun ComposeContentTestRule.clickCreateAndSend() { - onNodeWithText(getStringResource(R.string.send_create)).also { - it.performClick() - } -} - -private fun ComposeContentTestRule.clickConfirmation() { - onNodeWithText(getStringResource(R.string.send_confirm)).also { - it.performClick() - } -} - -private fun ComposeContentTestRule.assertOnForm() { - onNodeWithText(getStringResource(R.string.send_create)).also { - it.assertExists() - } -} - -private fun ComposeContentTestRule.assertOnConfirmation() { - onNodeWithText(getStringResource(R.string.send_confirm)).also { - it.assertExists() - } -} - -private fun ComposeContentTestRule.assertSendEnabled() { - onNodeWithText(getStringResource(R.string.send_create)).also { - it.assertIsEnabled() - } -} - -private fun ComposeContentTestRule.assertSendDisabled() { - onNodeWithText(getStringResource(R.string.send_create)).also { - it.assertIsNotEnabled() + assertEquals(1, testSetup.getOnBackCount()) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt index 40f22f67..16d8c8bd 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt @@ -1,5 +1,3 @@ -@file:Suppress("ktlint:filename") - package co.electriccoin.zcash.ui.screen.scan import androidx.activity.ComponentActivity diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt index e65cbd4c..410fb113 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt @@ -3,14 +3,27 @@ package co.electriccoin.zcash.ui.screen.send import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.viewModels +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cash.z.ecc.android.sdk.Synchronizer +import cash.z.ecc.android.sdk.model.UnifiedSpendingKey +import cash.z.ecc.android.sdk.model.Zatoshi +import cash.z.ecc.android.sdk.model.ZecSend +import cash.z.ecc.android.sdk.model.isFailedSubmit +import cash.z.ecc.android.sdk.model.isSubmitSuccess import cash.z.ecc.sdk.send +import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.ui.MainActivity import co.electriccoin.zcash.ui.screen.home.model.spendableBalance import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel +import co.electriccoin.zcash.ui.screen.send.ext.Saver +import co.electriccoin.zcash.ui.screen.send.model.SendStage import co.electriccoin.zcash.ui.screen.send.view.Send import kotlinx.coroutines.launch @@ -27,23 +40,76 @@ private fun WrapSend( goBack: () -> Unit ) { val walletViewModel by activity.viewModels() - val scope = rememberCoroutineScope() val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value val spendableBalance = walletViewModel.walletSnapshot.collectAsStateWithLifecycle().value?.spendableBalance() val spendingKey = walletViewModel.spendingKey.collectAsStateWithLifecycle().value + + WrapSend(synchronizer, spendableBalance, spendingKey, goBack) +} + +@VisibleForTesting +@Composable +internal fun WrapSend( + synchronizer: Synchronizer?, + spendableBalance: Zatoshi?, + spendingKey: UnifiedSpendingKey?, + goBack: () -> Unit +) { + val scope = rememberCoroutineScope() + + // For now, we're avoiding sub-navigation to keep the navigation logic simple. But this might + // change once deep-linking support is added. It depends on whether deep linking should do one of: + // 1. Use a different UI flow entirely + // 2. Show a pre-filled Send form + // 3. Go directly to the Confirmation screen + val (sendStage, setSendStage) = rememberSaveable { mutableStateOf(SendStage.Form) } + + val (zecSend, setZecSend) = rememberSaveable(stateSaver = ZecSend.Saver) { mutableStateOf(null) } + + val onBackAction = { + when (sendStage) { + SendStage.Form -> goBack() + SendStage.Confirmation -> setSendStage(SendStage.Form) + SendStage.Sending -> { /* no action - wait until done */ } + SendStage.SendFailure -> setSendStage(SendStage.Form) + SendStage.SendSuccessful -> goBack() + } + } + + BackHandler { + onBackAction() + } + if (null == synchronizer || null == spendableBalance || null == spendingKey) { // Display loading indicator } else { Send( mySpendableBalance = spendableBalance, - goBack = goBack, + sendStage = sendStage, + onSendStageChange = setSendStage, + zecSend = zecSend, + onZecSendChange = setZecSend, + onBack = onBackAction, onCreateAndSend = { scope.launch { - synchronizer.send(spendingKey, it) - goBack() + synchronizer.send(spendingKey, it).collect { + Twig.debug { "Sending transaction id: ${it.id}" } + + if (it.isSubmitSuccess()) { + setSendStage(SendStage.SendSuccessful) + Twig.debug { + "Transaction id:${it.id} submitted successfully at ${it.createTime} with " + + "${it.submitAttempts} attempts." + } + } else if (it.isFailedSubmit()) { + Twig.debug { "Transaction id:${it.id} submission failed with: ${it.errorMessage}." } + setSendStage(SendStage.SendFailure) + } + // All other states of Pending transaction mean waiting for one of the states above + } } } ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/ext/MemoExt.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/ext/MemoExt.kt new file mode 100644 index 00000000..366a5f2c --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/ext/MemoExt.kt @@ -0,0 +1,20 @@ +package co.electriccoin.zcash.ui.screen.send.ext + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import cash.z.ecc.android.sdk.model.Memo +import co.electriccoin.zcash.ui.R + +@Composable +@ReadOnlyComposable +internal fun Memo.valueOrEmptyChar(): String { + LocalConfiguration.current + return valueOrEmptyChar(LocalContext.current) +} + +internal fun Memo.valueOrEmptyChar(context: Context): String { + return value.ifEmpty { context.getString(R.string.empty_char) } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/ext/WalletAddressExt.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/ext/WalletAddressExt.kt index 20317cb5..bae96974 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/ext/WalletAddressExt.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/ext/WalletAddressExt.kt @@ -26,5 +26,5 @@ internal fun WalletAddress.abbreviated(context: Context): String { val firstFive = address.substring(0, ABBREVIATION_INDEX) val lastFive = address.substring(address.length - ABBREVIATION_INDEX, address.length) - return context.getString(R.string.send_abbreviated_address_format, firstFive, lastFive) + return context.getString(R.string.send_confirmation_abbreviated_address_format, firstFive, lastFive) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/SendStage.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/SendStage.kt index a6af5a04..9e84fb53 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/SendStage.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/SendStage.kt @@ -2,5 +2,8 @@ package co.electriccoin.zcash.ui.screen.send.model enum class SendStage { Form, - Confirmation + Confirmation, + Sending, + SendFailure, + SendSuccessful } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt index d834dfa7..51f4b674 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt @@ -48,8 +48,8 @@ import co.electriccoin.zcash.ui.design.component.PrimaryButton import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme.dimens import co.electriccoin.zcash.ui.screen.send.ext.ABBREVIATION_INDEX -import co.electriccoin.zcash.ui.screen.send.ext.Saver import co.electriccoin.zcash.ui.screen.send.ext.abbreviated +import co.electriccoin.zcash.ui.screen.send.ext.valueOrEmptyChar import co.electriccoin.zcash.ui.screen.send.model.SendStage @Composable @@ -59,47 +59,47 @@ fun PreviewSend() { GradientSurface { Send( mySpendableBalance = ZatoshiFixture.new(), - goBack = {}, - onCreateAndSend = {} + sendStage = SendStage.Form, + onSendStageChange = {}, + zecSend = null, + onZecSendChange = {}, + onCreateAndSend = {}, + onBack = {} ) } } } +@Suppress("LongParameterList") @OptIn(ExperimentalMaterial3Api::class) @Composable fun Send( mySpendableBalance: Zatoshi, - goBack: () -> Unit, + sendStage: SendStage, + onSendStageChange: (SendStage) -> Unit, + zecSend: ZecSend?, + onZecSendChange: (ZecSend) -> Unit, + onBack: () -> Unit, onCreateAndSend: (ZecSend) -> Unit ) { - // For now, we're avoiding sub-navigation to keep the navigation logic simple. But this might - // change once deep-linking support is added. It depends on whether deep linking should do one of: - // 1. Use a different UI flow entirely - // 2. Show a pre-filled Send form - // 3. Go directly to the press-and-hold confirmation - val (sendStage, setSendStage) = rememberSaveable { mutableStateOf(SendStage.Form) } - Scaffold(topBar = { - SendTopAppBar(onBack = { - when (sendStage) { - SendStage.Form -> goBack() - SendStage.Confirmation -> setSendStage(SendStage.Form) - } - }) + SendTopAppBar( + onBack = onBack, + showBackNavigationButton = sendStage != SendStage.Sending + ) }) { paddingValues -> SendMainContent( myBalance = mySpendableBalance, + onBack = onBack, sendStage = sendStage, - setSendStage = setSendStage, - onCreateAndSend = onCreateAndSend, + onSendStageChange = onSendStageChange, + zecSend = zecSend, + onZecSendChange = onZecSendChange, + onSendSubmit = onCreateAndSend, modifier = Modifier - .verticalScroll( - rememberScrollState() - ) .padding( top = paddingValues.calculateTopPadding() + dimens.spacingDefault, - bottom = dimens.spacingDefault, + bottom = paddingValues.calculateBottomPadding() + dimens.spacingDefault, start = dimens.spacingDefault, end = dimens.spacingDefault ) @@ -109,51 +109,83 @@ fun Send( @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun SendTopAppBar(onBack: () -> Unit) { - TopAppBar( - title = { Text(text = stringResource(id = R.string.send_title)) }, - navigationIcon = { - IconButton( - onClick = onBack - ) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = stringResource(R.string.send_back_content_description) - ) +private fun SendTopAppBar( + onBack: () -> Unit, + showBackNavigationButton: Boolean = true +) { + if (showBackNavigationButton) { + TopAppBar( + title = { Text(text = stringResource(id = R.string.send_title)) }, + navigationIcon = { + IconButton( + onClick = onBack + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.send_back_content_description) + ) + } } - } - ) + ) + } else { + TopAppBar(title = { Text(text = stringResource(id = R.string.send_title)) }) + } } @Suppress("LongParameterList") @Composable private fun SendMainContent( myBalance: Zatoshi, + zecSend: ZecSend?, + onZecSendChange: (ZecSend) -> Unit, + onBack: () -> Unit, sendStage: SendStage, - setSendStage: (SendStage) -> Unit, - onCreateAndSend: (ZecSend) -> Unit, + onSendStageChange: (SendStage) -> Unit, + onSendSubmit: (ZecSend) -> Unit, modifier: Modifier = Modifier ) { - val (zecSend, setZecSend) = rememberSaveable(stateSaver = ZecSend.Saver) { mutableStateOf(null) } - - if (sendStage == SendStage.Form || null == zecSend) { - SendForm( - myBalance = myBalance, - previousZecSend = zecSend, - onCreateAndSend = { - setSendStage(SendStage.Confirmation) - setZecSend(it) - }, - modifier = modifier - ) - } else { - Confirmation( - zecSend = zecSend, - onConfirmation = { - onCreateAndSend(zecSend) - }, - modifier = modifier - ) + when { + (sendStage == SendStage.Form || null == zecSend) -> { + SendForm( + myBalance = myBalance, + previousZecSend = zecSend, + onCreateZecSend = { + onSendStageChange(SendStage.Confirmation) + onZecSendChange(it) + }, + modifier = modifier + ) + } + (sendStage == SendStage.Confirmation) -> { + Confirmation( + zecSend = zecSend, + onConfirmation = { + onSendStageChange(SendStage.Sending) + onSendSubmit(zecSend) + }, + modifier = modifier + ) + } + (sendStage == SendStage.Sending) -> { + Sending( + zecSend = zecSend, + modifier = modifier + ) + } + (sendStage == SendStage.SendSuccessful) -> { + SendSuccessful( + zecSend = zecSend, + modifier = modifier, + onDone = onBack + ) + } + (sendStage == SendStage.SendFailure) -> { + SendFailure( + zecSend = zecSend, + modifier = modifier, + onDone = onBack + ) + } } } @@ -162,11 +194,10 @@ private fun SendMainContent( // TODO [#294]: DetektAll failed LongMethod @Suppress("LongMethod") @Composable -@OptIn(ExperimentalMaterial3Api::class) private fun SendForm( myBalance: Zatoshi, previousZecSend: ZecSend?, - onCreateAndSend: (ZecSend) -> Unit, + onCreateZecSend: (ZecSend) -> Unit, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -190,6 +221,7 @@ private fun SendForm( Column( modifier .fillMaxHeight() + .verticalScroll(rememberScrollState()) ) { Header( text = stringResource(id = R.string.send_balance, myBalance.toZecString()), @@ -268,7 +300,7 @@ private fun SendForm( ) when (zecSendValidation) { - is ZecSendExt.ZecSendValidation.Valid -> onCreateAndSend(zecSendValidation.zecSend) + is ZecSendExt.ZecSendValidation.Valid -> onCreateZecSend(zecSendValidation.zecSend) is ZecSendExt.ZecSendValidation.Invalid -> validation = zecSendValidation.validationErrors } }, @@ -286,18 +318,193 @@ private fun Confirmation( onConfirmation: () -> Unit, modifier: Modifier = Modifier ) { - Column(modifier) { - Text( + Column( + Modifier + .fillMaxHeight() + .verticalScroll( + rememberScrollState() + ) + .then(modifier) + ) { + Body( stringResource( - R.string.send_amount_and_address_format, + R.string.send_confirmation_amount_and_address_format, zecSend.amount.toZecString(), zecSend.destination.abbreviated() ) ) + if (zecSend.memo.value.isNotEmpty()) { + Body( + stringResource( + R.string.send_confirmation_memo_format, + zecSend.memo.value + ) + ) + } + + Spacer( + modifier = Modifier + .fillMaxHeight() + .weight(MINIMAL_WEIGHT) + ) PrimaryButton( + modifier = Modifier.padding(top = dimens.spacingSmall), onClick = onConfirmation, - text = stringResource(id = R.string.send_confirm) + text = stringResource(id = R.string.send_confirmation_button) + ) + } +} + +@Composable +private fun Sending( + zecSend: ZecSend, + modifier: Modifier = Modifier +) { + Column( + Modifier + .fillMaxHeight() + .verticalScroll( + rememberScrollState() + ) + .then(modifier) + ) { + Header( + text = stringResource( + R.string.send_in_progress_amount_format, + zecSend.amount.toZecString() + ), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Body( + text = zecSend.destination.abbreviated(), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + if (zecSend.memo.value.isNotEmpty()) { + Body( + stringResource( + R.string.send_in_progress_memo_format, + zecSend.memo.value + ), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer( + modifier = Modifier + .fillMaxHeight() + .weight(MINIMAL_WEIGHT) + ) + + Body( + modifier = Modifier + .padding(vertical = dimens.spacingSmall) + .fillMaxWidth(), + text = stringResource(R.string.send_in_progress_wait), + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun SendSuccessful( + zecSend: ZecSend, + onDone: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + Modifier + .fillMaxHeight() + .verticalScroll( + rememberScrollState() + ) + .then(modifier) + ) { + Header( + text = stringResource(R.string.send_successful_title), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(dimens.spacingDefault) + ) + + Body( + stringResource( + R.string.send_successful_amount_address_memo, + zecSend.amount.toZecString(), + zecSend.destination.abbreviated(), + zecSend.memo.valueOrEmptyChar() + ) + ) + + Spacer( + modifier = Modifier + .fillMaxHeight() + .weight(MINIMAL_WEIGHT) + ) + + PrimaryButton( + modifier = Modifier.padding(top = dimens.spacingSmall), + text = stringResource(R.string.send_successful_button), + onClick = onDone + ) + } +} + +@Composable +private fun SendFailure( + zecSend: ZecSend, + onDone: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + Modifier + .fillMaxHeight() + .verticalScroll( + rememberScrollState() + ) + .then(modifier) + ) { + Header( + text = stringResource(R.string.send_failure_title), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(dimens.spacingDefault) + ) + + Body( + stringResource( + R.string.send_failure_amount_address_memo, + zecSend.amount.toZecString(), + zecSend.destination.abbreviated(), + zecSend.memo.valueOrEmptyChar() + ) + ) + + Spacer( + modifier = Modifier + .fillMaxHeight() + .weight(MINIMAL_WEIGHT) + ) + + PrimaryButton( + modifier = Modifier.padding(top = dimens.spacingSmall), + text = stringResource(R.string.send_failure_button), + onClick = onDone ) } } diff --git a/ui-lib/src/main/res/ui/common/values/strings.xml b/ui-lib/src/main/res/ui/common/values/strings.xml index 61b68814..205371d2 100644 --- a/ui-lib/src/main/res/ui/common/values/strings.xml +++ b/ui-lib/src/main/res/ui/common/values/strings.xml @@ -1,3 +1,4 @@ Unavailable + - diff --git a/ui-lib/src/main/res/ui/send/values/strings.xml b/ui-lib/src/main/res/ui/send/values/strings.xml index 4d198d33..230430f1 100644 --- a/ui-lib/src/main/res/ui/send/values/strings.xml +++ b/ui-lib/src/main/res/ui/send/values/strings.xml @@ -7,11 +7,23 @@ Who would you like to send ZEC to? How much? Memo - Send - Send %1$s ZEC to %2$s? - %1$s%2$s - Press to send ZEC + Send %1$s ZEC to %2$s? + Memo: %1$s + %1$s%2$s + Press to send ZEC + + Sending %1$s ZEC to + with a memo: %1$s + Please wait + + Sending failure + Sending failed for: %1$s ZEC to %2$s with a memo: %3$s + Back + + Sending successful + Sending succeeded for: %1$s ZEC to %2$s with a memo: %3$s + Close diff --git a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt index aab36998..4c712641 100644 --- a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt +++ b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.test.core.app.ApplicationProvider import androidx.test.core.graphics.writeToTestStorage +import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onView import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.screenshot.captureToBitmap @@ -33,6 +34,7 @@ import androidx.test.filters.SdkSuppress import cash.z.ecc.android.sdk.fixture.WalletAddressFixture import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.SeedPhrase +import cash.z.ecc.sdk.fixture.MemoFixture import cash.z.ecc.sdk.fixture.SeedPhraseFixture import co.electriccoin.zcash.configuration.model.map.StringConfiguration import co.electriccoin.zcash.spackle.FirebaseTestLabUtil @@ -532,25 +534,56 @@ private fun sendZecScreenshots(resContext: Context, tag: String, composeTestRule it.assertExists() } + // Screenshot: Empty form ScreenshotTest.takeScreenshot(tag, "Send 1") composeTestRule.onNodeWithText(resContext.getString(R.string.send_amount)).also { val separators = MonetarySeparators.current() - it.performTextInput("{${separators.decimal}}123") + it.performTextInput("0${separators.decimal}123") } composeTestRule.onNodeWithText(resContext.getString(R.string.send_to)).also { it.performTextInput(WalletAddressFixture.UNIFIED_ADDRESS_STRING) } + composeTestRule.onNodeWithText(resContext.getString(R.string.send_memo)).also { + it.performTextInput(MemoFixture.MEMO_STRING) + } + + // To close soft keyboard to reveal the send button + Espresso.closeSoftKeyboard() + + // Screenshot: Fulfilled form + ScreenshotTest.takeScreenshot(tag, "Send 2") + composeTestRule.onNodeWithText(resContext.getString(R.string.send_create)).also { it.performClick() } - composeTestRule.waitForIdle() + /* + TODO [#817]: Screenshot test on Send with pseudolocales problem + TODO [#817]: https://github.com/zcash/secant-android-wallet/issues/817 + // Screenshot: Confirmation + ScreenshotTest.takeScreenshot(tag, "Send 3") - ScreenshotTest.takeScreenshot(tag, "Send 2") + composeTestRule.onNodeWithText(resContext.getString(R.string.send_confirmation_button)).also { + it.performClick() + } + + // Screenshot: Sending + ScreenshotTest.takeScreenshot(tag, "Send 4") + + // Note: this is potentially a long running waiting for the transaction submit result + // Remove this last section of taking screenshot if it turns out to be problematic + composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { + composeTestRule.onAllNodesWithText(resContext.getString(R.string.send_in_progress_wait)) + .fetchSemanticsNodes().isEmpty() + } + + // Screenshot: Result + ScreenshotTest.takeScreenshot(tag, "Send 5") + */ } private fun supportScreenshots(resContext: Context, tag: String, composeTestRule: ComposeTestRule) {