[#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 <git@carterjernigan.com>
This commit is contained in:
Honza Rychnovsky 2023-03-27 08:25:36 +02:00 committed by GitHub
parent 42e640b874
commit 543d5f2b59
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1155 additions and 222 deletions

View File

@ -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)
}

View File

@ -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<List<TransactionOverview>>
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<BlockHeight?>
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<WalletBalance?>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val pendingTransactions: Flow<List<PendingTransaction>>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val processorInfo: Flow<CompactBlockProcessor.ProcessorInfo>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val progress: Flow<Int>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val receivedTransactions: Flow<List<Transaction.Received>>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val saplingBalances: StateFlow<WalletBalance?>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val sentTransactions: Flow<List<Transaction.Sent>>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val status: Flow<Synchronizer.Status>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val transparentBalances: StateFlow<WalletBalance?>
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<String> {
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<TransactionRecipient> {
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<PendingTransaction> {
return emptyFlow()
}
override fun shieldFunds(usk: UnifiedSpendingKey, memo: String): Flow<PendingTransaction> {
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()
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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())
}
}

View File

@ -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())
}
}

View File

@ -1,5 +1,3 @@
@file:Suppress("ktlint:filename")
package co.electriccoin.zcash.ui.screen.scan
import androidx.activity.ComponentActivity

View File

@ -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<WalletViewModel>()
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
}
}
}
)

View File

@ -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) }
}

View File

@ -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)
}

View File

@ -2,5 +2,8 @@ package co.electriccoin.zcash.ui.screen.send.model
enum class SendStage {
Form,
Confirmation
Confirmation,
Sending,
SendFailure,
SendSuccessful
}

View File

@ -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
)
}
}

View File

@ -1,3 +1,4 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="fiat_currency_conversion_rate_unavailable">Unavailable</string>
<string name="empty_char">-</string>
</resources>

View File

@ -7,11 +7,23 @@
<string name="send_to">Who would you like to send ZEC to?</string>
<string name="send_amount">How much?</string>
<string name="send_memo">Memo</string>
<string name="send_create">Send</string>
<string name="send_amount_and_address_format" formatted="true">Send <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC to <xliff:g id="address" example="zs1g7cqw … mvyzgm">%2$s</xliff:g>?</string>
<string name="send_abbreviated_address_format" formatted="true"><xliff:g id="first_five" example="zs1g7">%1$s</xliff:g><xliff:g id="last_five" example="mvyzg">%2$s</xliff:g></string>
<string name="send_confirm">Press to send ZEC</string>
<string name="send_confirmation_amount_and_address_format" formatted="true">Send <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC to <xliff:g id="address" example="zs1g7cqw … mvyzgm">%2$s</xliff:g>?</string>
<string name="send_confirmation_memo_format" formatted="true">Memo: <xliff:g id="memo" example="for Veronika">%1$s</xliff:g></string>
<string name="send_confirmation_abbreviated_address_format" formatted="true"><xliff:g id="first_five" example="zs1g7">%1$s</xliff:g><xliff:g id="last_five" example="mvyzg">%2$s</xliff:g></string>
<string name="send_confirmation_button">Press to send ZEC</string>
<string name="send_in_progress_amount_format" formatted="true">Sending <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC to</string>
<string name="send_in_progress_memo_format" formatted="true">with a memo: <xliff:g id="memo" example="for Veronika">%1$s</xliff:g></string>
<string name="send_in_progress_wait">Please wait</string>
<string name="send_failure_title">Sending failure</string>
<string name="send_failure_amount_address_memo" formatted="true">Sending failed for: <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC to <xliff:g id="address" example="zs1g7cqw … mvyzgm">%2$s</xliff:g> with a memo: <xliff:g id="memo" example="for Veronika">%3$s</xliff:g></string>
<string name="send_failure_button">Back</string>
<string name="send_successful_title">Sending successful</string>
<string name="send_successful_amount_address_memo" formatted="true">Sending succeeded for: <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC to <xliff:g id="address" example="zs1g7cqw … mvyzgm">%2$s</xliff:g> with a memo: <xliff:g id="memo" example="for Veronika">%3$s</xliff:g></string>
<string name="send_successful_button">Close</string>
</resources>

View File

@ -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) {