[#791] Add Scan To Send

* [#791] Add Scan Button To Send Screen

- Switched Send.Form screen input fields ordering to match the MFP design
- Added Scan button to Send.Form
- Changed navigation to pass the scanned result to the Send.Form screen
- Added leading and trailing icons to FormTextField component
- Created SendArgumentsWrapper to pass any future Zcash URL parsed parameters

* Report invalid address scanned

- Merged composes to provide snackbar after invalid address scanned

* Add SendView tests

- Added one for scanner click and one for initial send arguments inserted
- This led to input and check amount and memo as part of the SendArgumentsWrapper too to have it prepare for future Zcash URI parsing

* Static analysis warnings fixes

* Don't make camera feature required

- And hide the scanner button on Send.Form when any camera is not supported by the device

* Let SendArgumentsWrapperFixture use SDK fixtures

* Tweak hint text

We need better text, but this is at least more correct than the previous iteration.

* Replace assertTextContains with assertTextEquals

- To compare inputs texts to an exact match to avoid potentially missing bugs if there is extra text in the field that we're not expecting

* Fix ktlint warning

---------

Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
Honza Rychnovsky 2023-03-30 09:58:20 +02:00 committed by GitHub
parent 136e56ea9a
commit 5296dc5ea9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 321 additions and 82 deletions

View File

@ -57,5 +57,5 @@ This section is optional and is required only if you'd like to test on an Androi
1. Grant the Camera permission with one of the previous procedures 1. Grant the Camera permission with one of the previous procedures
1. Create QR code from the valid Zcash wallet address. You can use, for example, the [QR Code Generator](https://www.qr-code-generator.com/) tool. 1. Create QR code from the valid Zcash wallet address. You can use, for example, the [QR Code Generator](https://www.qr-code-generator.com/) tool.
1. Scan the created QR code 1. Scan the created QR code
1. The code should be scanned but not validated 1. The code should be scanned but not validated (error message is displayed).
1. The app UI should not be changed and the Camera view should be still available for scanning another codes 1. The app UI should not be changed and the Camera view should be still available for scanning another codes

View File

@ -18,6 +18,8 @@ fun FormTextField(
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null, label: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors: TextFieldColors = TextFieldDefaults.textFieldColors( colors: TextFieldColors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent containerColor = Color.Transparent
@ -29,6 +31,8 @@ fun FormTextField(
label = label, label = label,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
colors = colors, colors = colors,
modifier = modifier modifier = modifier,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon
) )
} }

View File

@ -0,0 +1,26 @@
package co.electriccoin.zcash.ui.fixture
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
internal object SendArgumentsWrapperFixture {
val RECIPIENT_ADDRESS = WalletFixture.Alice.getAddresses(ZcashNetwork.Testnet).unified
val MEMO = MemoFixture.new("Thanks for lunch").value
val AMOUNT = ZatoshiFixture.new(1)
fun amountToFixtureZecString(amount: Zatoshi?) = amount?.toZecString()
fun new(
recipientAddress: String? = RECIPIENT_ADDRESS,
amount: Zatoshi? = AMOUNT,
memo: String? = MEMO
) = SendArgumentsWrapper(
recipientAddress = recipientAddress,
amount = amountToFixtureZecString(amount),
memo = memo
)
}

View File

@ -20,6 +20,12 @@ internal fun ComposeContentTestRule.clickBack() {
} }
} }
internal fun ComposeContentTestRule.clickScanner() {
onNodeWithContentDescription(getStringResource(R.string.send_scan_content_description)).also {
it.performClick()
}
}
internal fun ComposeContentTestRule.setValidAmount() { internal fun ComposeContentTestRule.setValidAmount() {
onNodeWithText(getStringResource(R.string.send_amount)).also { onNodeWithText(getStringResource(R.string.send_amount)).also {
val separators = MonetarySeparators.current() val separators = MonetarySeparators.current()

View File

@ -9,6 +9,7 @@ import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.sdk.fixture.ZatoshiFixture import cash.z.ecc.sdk.fixture.ZatoshiFixture
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.send.ext.Saver import co.electriccoin.zcash.ui.screen.send.ext.Saver
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.SendStage import co.electriccoin.zcash.ui.screen.send.model.SendStage
import co.electriccoin.zcash.ui.screen.send.view.Send import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -18,10 +19,13 @@ import java.util.concurrent.atomic.AtomicInteger
class SendViewTestSetup( class SendViewTestSetup(
private val composeTestRule: ComposeContentTestRule, private val composeTestRule: ComposeContentTestRule,
private val initialState: SendStage, private val initialState: SendStage,
private val initialZecSend: ZecSend? private val initialZecSend: ZecSend?,
private val initialSendArgumentWrapper: SendArgumentsWrapper?,
private val hasCameraFeature: Boolean
) { ) {
private val onBackCount = AtomicInteger(0) private val onBackCount = AtomicInteger(0)
private val onCreateCount = AtomicInteger(0) private val onCreateCount = AtomicInteger(0)
private val onScannerCount = AtomicInteger(0)
val mutableActionExecuted = MutableStateFlow(false) val mutableActionExecuted = MutableStateFlow(false)
@Volatile @Volatile
@ -40,6 +44,11 @@ class SendViewTestSetup(
return onCreateCount.get() return onCreateCount.get()
} }
fun getOnScannerCount(): Int {
composeTestRule.waitForIdle()
return onScannerCount.get()
}
fun getLastZecSend(): ZecSend? { fun getLastZecSend(): ZecSend? {
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
return lastZecSend return lastZecSend
@ -82,6 +91,7 @@ class SendViewTestSetup(
Send( Send(
mySpendableBalance = ZatoshiFixture.new(), mySpendableBalance = ZatoshiFixture.new(),
sendStage = sendStage, sendStage = sendStage,
sendArgumentsWrapper = initialSendArgumentWrapper,
onSendStageChange = setSendStage, onSendStageChange = setSendStage,
zecSend = zecSend, zecSend = zecSend,
onZecSendChange = setZecSend, onZecSendChange = setZecSend,
@ -90,7 +100,11 @@ class SendViewTestSetup(
onCreateCount.incrementAndGet() onCreateCount.incrementAndGet()
lastZecSend = it lastZecSend = it
mutableActionExecuted.update { true } mutableActionExecuted.update { true }
} },
onQrScannerOpen = {
onScannerCount.incrementAndGet()
},
hasCameraFeature = hasCameraFeature
) )
} }
} }

View File

@ -1,6 +1,6 @@
package co.electriccoin.zcash.ui.screen.send.integration package co.electriccoin.zcash.ui.screen.send.integration
import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
@ -51,10 +51,13 @@ class SendViewIntegrationTest {
restorationTester.setContent { restorationTester.setContent {
WrapSend( WrapSend(
sendArgumentsWrapper = null,
synchronizer = synchronizer, synchronizer = synchronizer,
spendableBalance = balance, spendableBalance = balance,
spendingKey = spendingKey, spendingKey = spendingKey,
goBack = {} goToQrScanner = {},
goBack = {},
hasCameraFeature = true
) )
} }
@ -77,14 +80,24 @@ class SendViewIntegrationTest {
composeTestRule.clickBack() composeTestRule.clickBack()
composeTestRule.assertOnForm() composeTestRule.assertOnForm()
// And check recreated form values too // And check recreated form values too. Note also that we don't check the amount field value, as it's changed
// We use that the assertTextContains searches in SemanticsProperties.EditableText too // by validation mechanisms
// Note also that we don't check the amount field value, as it's changed by validation mechanisms
// We use that the assertTextEquals searches in SemanticsProperties.EditableText too, although to be able to
// compare its editable value to an exact match we need to pass all its texts
composeTestRule.onNodeWithText(getStringResource(R.string.send_to)).also { composeTestRule.onNodeWithText(getStringResource(R.string.send_to)).also {
it.assertTextContains(ZecSendFixture.ADDRESS) it.assertTextEquals(
getStringResource(R.string.send_to),
ZecSendFixture.ADDRESS,
includeEditableText = true
)
} }
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also { composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also {
it.assertTextContains(ZecSendFixture.MEMO.value) it.assertTextEquals(
getStringResource(R.string.send_memo),
ZecSendFixture.MEMO.value,
includeEditableText = true
)
} }
} }
} }

View File

@ -31,7 +31,9 @@ class SendViewAndroidTest : UiTestPrerequisites() {
) = SendViewTestSetup( ) = SendViewTestSetup(
composeTestRule, composeTestRule,
sendStage, sendStage,
zecSend zecSend,
null,
true
).apply { ).apply {
setDefaultContent() setDefaultContent()
} }

View File

@ -1,6 +1,7 @@
package co.electriccoin.zcash.ui.screen.send.view package co.electriccoin.zcash.ui.screen.send.view
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
@ -16,6 +17,7 @@ import cash.z.ecc.sdk.fixture.ZecRequestFixture
import cash.z.ecc.sdk.fixture.ZecSendFixture import cash.z.ecc.sdk.fixture.ZecSendFixture
import co.electriccoin.zcash.test.UiTestPrerequisites import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.fixture.SendArgumentsWrapperFixture
import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup
import co.electriccoin.zcash.ui.screen.send.assertOnConfirmation import co.electriccoin.zcash.ui.screen.send.assertOnConfirmation
import co.electriccoin.zcash.ui.screen.send.assertOnForm import co.electriccoin.zcash.ui.screen.send.assertOnForm
@ -27,6 +29,8 @@ import co.electriccoin.zcash.ui.screen.send.assertSendEnabled
import co.electriccoin.zcash.ui.screen.send.clickBack import co.electriccoin.zcash.ui.screen.send.clickBack
import co.electriccoin.zcash.ui.screen.send.clickConfirmation import co.electriccoin.zcash.ui.screen.send.clickConfirmation
import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend
import co.electriccoin.zcash.ui.screen.send.clickScanner
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.SendStage import co.electriccoin.zcash.ui.screen.send.model.SendStage
import co.electriccoin.zcash.ui.screen.send.setAmount import co.electriccoin.zcash.ui.screen.send.setAmount
import co.electriccoin.zcash.ui.screen.send.setMemo import co.electriccoin.zcash.ui.screen.send.setMemo
@ -51,11 +55,15 @@ class SendViewTest : UiTestPrerequisites() {
private fun newTestSetup( private fun newTestSetup(
sendStage: SendStage = SendStage.Form, sendStage: SendStage = SendStage.Form,
zecSend: ZecSend? = null zecSend: ZecSend? = null,
sendArgumentsWrapper: SendArgumentsWrapper? = null,
hasCameraFeature: Boolean = true
) = SendViewTestSetup( ) = SendViewTestSetup(
composeTestRule, composeTestRule,
sendStage, sendStage,
zecSend zecSend,
sendArgumentsWrapper,
hasCameraFeature
).apply { ).apply {
setDefaultContent() setDefaultContent()
} }
@ -395,4 +403,86 @@ class SendViewTest : UiTestPrerequisites() {
assertEquals(1, testSetup.getOnBackCount()) assertEquals(1, testSetup.getOnBackCount())
} }
@Test
@MediumTest
fun scanner_button_on_form_hit() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnScannerCount())
composeTestRule.clickScanner()
assertEquals(1, testSetup.getOnScannerCount())
}
@Test
@MediumTest
fun input_arguments_to_form() {
newTestSetup(
sendStage = SendStage.Form,
sendArgumentsWrapper = SendArgumentsWrapperFixture.new(
recipientAddress = SendArgumentsWrapperFixture.RECIPIENT_ADDRESS,
amount = SendArgumentsWrapperFixture.AMOUNT,
memo = SendArgumentsWrapperFixture.MEMO
),
zecSend = null
)
composeTestRule.assertOnForm()
// We use that the assertTextEquals searches in SemanticsProperties.EditableText too, although to be able to
// compare its editable value to an exact match we need to pass all its texts
composeTestRule.onNodeWithText(getStringResource(R.string.send_to)).also {
it.assertTextEquals(
getStringResource(R.string.send_to),
SendArgumentsWrapperFixture.RECIPIENT_ADDRESS,
includeEditableText = true
)
}
composeTestRule.onNodeWithText(getStringResource(R.string.send_amount)).also {
it.assertTextEquals(
getStringResource(R.string.send_amount),
SendArgumentsWrapperFixture.amountToFixtureZecString(SendArgumentsWrapperFixture.AMOUNT)!!,
includeEditableText = true
)
}
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also {
it.assertTextEquals(
getStringResource(R.string.send_memo),
SendArgumentsWrapperFixture.MEMO,
includeEditableText = true
)
}
}
@Test
@MediumTest
fun device_has_camera_feature() {
newTestSetup(
sendStage = SendStage.Form,
hasCameraFeature = true
)
composeTestRule.assertOnForm()
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.send_scan_content_description)).also {
it.assertExists()
}
}
@Test
@MediumTest
fun device_has_not_camera_feature() {
newTestSetup(
sendStage = SendStage.Form,
hasCameraFeature = false
)
composeTestRule.assertOnForm()
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.send_scan_content_description)).also {
it.assertDoesNotExist()
}
}
} }

View File

@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application <application
android:icon="@mipmap/ic_launcher_square" android:icon="@mipmap/ic_launcher_square"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"

View File

@ -7,6 +7,10 @@ import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.NavigationArguments.SEND_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.SEND_MEMO
import co.electriccoin.zcash.ui.NavigationArguments.SEND_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.HOME import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.RECEIVE import co.electriccoin.zcash.ui.NavigationTargets.RECEIVE
@ -27,6 +31,7 @@ import co.electriccoin.zcash.ui.screen.request.WrapRequest
import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator
import co.electriccoin.zcash.ui.screen.seed.WrapSeed import co.electriccoin.zcash.ui.screen.seed.WrapSeed
import co.electriccoin.zcash.ui.screen.send.WrapSend import co.electriccoin.zcash.ui.screen.send.WrapSend
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.settings.WrapSettings import co.electriccoin.zcash.ui.screen.settings.WrapSettings
import co.electriccoin.zcash.ui.screen.support.WrapSupport import co.electriccoin.zcash.ui.screen.support.WrapSupport
import co.electriccoin.zcash.ui.screen.update.WrapCheckForUpdate import co.electriccoin.zcash.ui.screen.update.WrapCheckForUpdate
@ -85,8 +90,22 @@ internal fun MainActivity.Navigation() {
composable(REQUEST) { composable(REQUEST) {
WrapRequest(goBack = { navController.popBackStackJustOnce(REQUEST) }) WrapRequest(goBack = { navController.popBackStackJustOnce(REQUEST) })
} }
composable(SEND) { composable(SEND) { backStackEntry ->
WrapSend(goBack = { navController.popBackStackJustOnce(SEND) }) WrapSend(
goToQrScanner = {
Twig.debug { "Opening Qr Scanner Screen" }
navController.navigateJustOnce(SCAN)
},
goBack = { navController.popBackStackJustOnce(SEND) },
sendArgumentsWrapper = SendArgumentsWrapper(
recipientAddress = backStackEntry.savedStateHandle[SEND_RECIPIENT_ADDRESS],
amount = backStackEntry.savedStateHandle[SEND_AMOUNT],
memo = backStackEntry.savedStateHandle[SEND_MEMO]
)
)
backStackEntry.savedStateHandle.remove<String>(SEND_RECIPIENT_ADDRESS)
backStackEntry.savedStateHandle.remove<String>(SEND_AMOUNT)
backStackEntry.savedStateHandle.remove<String>(SEND_MEMO)
} }
composable(SUPPORT) { composable(SUPPORT) {
// Pop back stack won't be right if we deep link into support // Pop back stack won't be right if we deep link into support
@ -97,11 +116,14 @@ internal fun MainActivity.Navigation() {
} }
composable(SCAN) { composable(SCAN) {
WrapScanValidator( WrapScanValidator(
onScanValid = { onScanValid = { result ->
// TODO [#449] https://github.com/zcash/secant-android-wallet/issues/449 // At this point we only pass recipient address
navController.navigateJustOnce(SEND) { navController.previousBackStackEntry?.savedStateHandle?.apply {
popUpTo(HOME) { inclusive = false } set(SEND_RECIPIENT_ADDRESS, result)
set(SEND_AMOUNT, null)
set(SEND_MEMO, null)
} }
navController.popBackStackJustOnce(SCAN)
}, },
goBack = { navController.popBackStackJustOnce(SCAN) } goBack = { navController.popBackStackJustOnce(SCAN) }
) )
@ -137,6 +159,12 @@ private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: Strin
popBackStack() popBackStack()
} }
object NavigationArguments {
const val SEND_RECIPIENT_ADDRESS = "send_recipient_address"
const val SEND_AMOUNT = "send_amount"
const val SEND_MEMO = "send_memo"
}
object NavigationTargets { object NavigationTargets {
const val HOME = "home" const val HOME = "home"

View File

@ -7,7 +7,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import co.electriccoin.zcash.ui.MainActivity import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
@ -20,7 +19,7 @@ internal fun MainActivity.WrapScanValidator(
onScanValid: (address: String) -> Unit, onScanValid: (address: String) -> Unit,
goBack: () -> Unit goBack: () -> Unit
) { ) {
WrapScanValidator( WrapScan(
this, this,
onScanValid = onScanValid, onScanValid = onScanValid,
goBack = goBack goBack = goBack
@ -28,7 +27,7 @@ internal fun MainActivity.WrapScanValidator(
} }
@Composable @Composable
private fun WrapScanValidator( fun WrapScan(
activity: ComponentActivity, activity: ComponentActivity,
onScanValid: (address: String) -> Unit, onScanValid: (address: String) -> Unit,
goBack: () -> Unit goBack: () -> Unit
@ -37,50 +36,41 @@ private fun WrapScanValidator(
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
if (synchronizer == null) {
// Display loading indicator
} else {
WrapScan(
activity,
onScanned = { result ->
activity.lifecycleScope.launch {
val isAddressValid = !synchronizer.validateAddress(result).isNotValid
if (isAddressValid) {
onScanValid(result)
}
}
},
goBack = goBack
)
}
}
@Composable
fun WrapScan(
activity: ComponentActivity,
onScanned: (result: String) -> Unit,
goBack: () -> Unit
) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Scan( if (synchronizer == null) {
snackbarHostState, // Display loading indicator
onBack = goBack, } else {
onScanned = onScanned, Scan(
onOpenSettings = { snackbarHostState = snackbarHostState,
runCatching { onBack = goBack,
activity.startActivity(SettingsUtil.newSettingsIntent(activity.packageName)) onScanned = { result ->
}.onFailure {
// This case should not really happen, as the Settings app should be available on every
// Android device, but we need to handle it somehow.
scope.launch { scope.launch {
snackbarHostState.showSnackbar( val isAddressValid = !synchronizer.validateAddress(result).isNotValid
message = activity.getString(R.string.scan_settings_open_failed) if (isAddressValid) {
) onScanValid(result)
} else {
snackbarHostState.showSnackbar(
message = activity.getString(R.string.scan_validation_invalid_address)
)
}
} }
} },
}, onOpenSettings = {
onScanStateChanged = {} runCatching {
) activity.startActivity(SettingsUtil.newSettingsIntent(activity.packageName))
}.onFailure {
// This case should not really happen, as the Settings app should be available on every
// Android device, but we need to handle it somehow.
scope.launch {
snackbarHostState.showSnackbar(
message = activity.getString(R.string.scan_settings_open_failed)
)
}
}
},
onScanStateChanged = {}
)
}
} }

View File

@ -2,6 +2,7 @@
package co.electriccoin.zcash.ui.screen.send package co.electriccoin.zcash.ui.screen.send
import android.content.pm.PackageManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.viewModels import androidx.activity.viewModels
@ -23,22 +24,29 @@ import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.screen.home.model.spendableBalance import co.electriccoin.zcash.ui.screen.home.model.spendableBalance
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel 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.ext.Saver
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.SendStage import co.electriccoin.zcash.ui.screen.send.model.SendStage
import co.electriccoin.zcash.ui.screen.send.view.Send import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
internal fun MainActivity.WrapSend( internal fun MainActivity.WrapSend(
sendArgumentsWrapper: SendArgumentsWrapper?,
goToQrScanner: () -> Unit,
goBack: () -> Unit goBack: () -> Unit
) { ) {
WrapSend(this, goBack) WrapSend(this, sendArgumentsWrapper, goToQrScanner, goBack)
} }
@Composable @Composable
private fun WrapSend( private fun WrapSend(
activity: ComponentActivity, activity: ComponentActivity,
sendArgumentsWrapper: SendArgumentsWrapper?,
goToQrScanner: () -> Unit,
goBack: () -> Unit goBack: () -> Unit
) { ) {
val hasCameraFeature = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
val walletViewModel by activity.viewModels<WalletViewModel>() val walletViewModel by activity.viewModels<WalletViewModel>()
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
@ -47,16 +55,20 @@ private fun WrapSend(
val spendingKey = walletViewModel.spendingKey.collectAsStateWithLifecycle().value val spendingKey = walletViewModel.spendingKey.collectAsStateWithLifecycle().value
WrapSend(synchronizer, spendableBalance, spendingKey, goBack) WrapSend(sendArgumentsWrapper, synchronizer, spendableBalance, spendingKey, goToQrScanner, goBack, hasCameraFeature)
} }
@Suppress("LongParameterList")
@VisibleForTesting @VisibleForTesting
@Composable @Composable
internal fun WrapSend( internal fun WrapSend(
sendArgumentsWrapper: SendArgumentsWrapper?,
synchronizer: Synchronizer?, synchronizer: Synchronizer?,
spendableBalance: Zatoshi?, spendableBalance: Zatoshi?,
spendingKey: UnifiedSpendingKey?, spendingKey: UnifiedSpendingKey?,
goBack: () -> Unit goToQrScanner: () -> Unit,
goBack: () -> Unit,
hasCameraFeature: Boolean
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -88,6 +100,7 @@ internal fun WrapSend(
} else { } else {
Send( Send(
mySpendableBalance = spendableBalance, mySpendableBalance = spendableBalance,
sendArgumentsWrapper = sendArgumentsWrapper,
sendStage = sendStage, sendStage = sendStage,
onSendStageChange = setSendStage, onSendStageChange = setSendStage,
zecSend = zecSend, zecSend = zecSend,
@ -111,7 +124,9 @@ internal fun WrapSend(
// All other states of Pending transaction mean waiting for one of the states above // All other states of Pending transaction mean waiting for one of the states above
} }
} }
} },
onQrScannerOpen = goToQrScanner,
hasCameraFeature = hasCameraFeature
) )
} }
} }

View File

@ -0,0 +1,7 @@
package co.electriccoin.zcash.ui.screen.send.model
data class SendArgumentsWrapper(
val recipientAddress: String? = null,
val amount: String? = null,
val memo: String? = null
)

View File

@ -12,6 +12,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -50,6 +51,7 @@ 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.ABBREVIATION_INDEX
import co.electriccoin.zcash.ui.screen.send.ext.abbreviated 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.ext.valueOrEmptyChar
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.SendStage import co.electriccoin.zcash.ui.screen.send.model.SendStage
@Composable @Composable
@ -59,12 +61,15 @@ fun PreviewSend() {
GradientSurface { GradientSurface {
Send( Send(
mySpendableBalance = ZatoshiFixture.new(), mySpendableBalance = ZatoshiFixture.new(),
sendArgumentsWrapper = null,
sendStage = SendStage.Form, sendStage = SendStage.Form,
onSendStageChange = {}, onSendStageChange = {},
zecSend = null, zecSend = null,
onZecSendChange = {}, onZecSendChange = {},
onCreateAndSend = {}, onCreateAndSend = {},
onBack = {} onQrScannerOpen = {},
onBack = {},
hasCameraFeature = true
) )
} }
} }
@ -75,12 +80,15 @@ fun PreviewSend() {
@Composable @Composable
fun Send( fun Send(
mySpendableBalance: Zatoshi, mySpendableBalance: Zatoshi,
sendArgumentsWrapper: SendArgumentsWrapper?,
sendStage: SendStage, sendStage: SendStage,
onSendStageChange: (SendStage) -> Unit, onSendStageChange: (SendStage) -> Unit,
zecSend: ZecSend?, zecSend: ZecSend?,
onZecSendChange: (ZecSend) -> Unit, onZecSendChange: (ZecSend) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onCreateAndSend: (ZecSend) -> Unit onCreateAndSend: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit,
hasCameraFeature: Boolean
) { ) {
Scaffold(topBar = { Scaffold(topBar = {
SendTopAppBar( SendTopAppBar(
@ -90,12 +98,15 @@ fun Send(
}) { paddingValues -> }) { paddingValues ->
SendMainContent( SendMainContent(
myBalance = mySpendableBalance, myBalance = mySpendableBalance,
sendArgumentsWrapper = sendArgumentsWrapper,
onBack = onBack, onBack = onBack,
sendStage = sendStage, sendStage = sendStage,
onSendStageChange = onSendStageChange, onSendStageChange = onSendStageChange,
zecSend = zecSend, zecSend = zecSend,
onZecSendChange = onZecSendChange, onZecSendChange = onZecSendChange,
onSendSubmit = onCreateAndSend, onSendSubmit = onCreateAndSend,
onQrScannerOpen = onQrScannerOpen,
hasCameraFeature = hasCameraFeature,
modifier = Modifier modifier = Modifier
.padding( .padding(
top = paddingValues.calculateTopPadding() + dimens.spacingDefault, top = paddingValues.calculateTopPadding() + dimens.spacingDefault,
@ -136,23 +147,29 @@ private fun SendTopAppBar(
@Composable @Composable
private fun SendMainContent( private fun SendMainContent(
myBalance: Zatoshi, myBalance: Zatoshi,
sendArgumentsWrapper: SendArgumentsWrapper?,
zecSend: ZecSend?, zecSend: ZecSend?,
onZecSendChange: (ZecSend) -> Unit, onZecSendChange: (ZecSend) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
sendStage: SendStage, sendStage: SendStage,
onSendStageChange: (SendStage) -> Unit, onSendStageChange: (SendStage) -> Unit,
onSendSubmit: (ZecSend) -> Unit, onSendSubmit: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit,
hasCameraFeature: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when { when {
(sendStage == SendStage.Form || null == zecSend) -> { (sendStage == SendStage.Form || null == zecSend) -> {
SendForm( SendForm(
myBalance = myBalance, myBalance = myBalance,
sendArgumentsWrapper = sendArgumentsWrapper,
previousZecSend = zecSend, previousZecSend = zecSend,
onCreateZecSend = { onCreateZecSend = {
onSendStageChange(SendStage.Confirmation) onSendStageChange(SendStage.Confirmation)
onZecSendChange(it) onZecSendChange(it)
}, },
onQrScannerOpen = onQrScannerOpen,
hasCameraFeature = hasCameraFeature,
modifier = modifier modifier = modifier
) )
} }
@ -192,12 +209,15 @@ private fun SendMainContent(
// TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button. // TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button.
// TODO [#288]: TextField component can't do long-press backspace. // TODO [#288]: TextField component can't do long-press backspace.
// TODO [#294]: DetektAll failed LongMethod // TODO [#294]: DetektAll failed LongMethod
@Suppress("LongMethod") @Suppress("LongMethod", "LongParameterList")
@Composable @Composable
private fun SendForm( private fun SendForm(
myBalance: Zatoshi, myBalance: Zatoshi,
sendArgumentsWrapper: SendArgumentsWrapper?,
previousZecSend: ZecSend?, previousZecSend: ZecSend?,
onCreateZecSend: (ZecSend) -> Unit, onCreateZecSend: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit,
hasCameraFeature: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -218,6 +238,16 @@ private fun SendForm(
mutableStateOf<Set<ZecSendExt.ZecSendValidation.Invalid.ValidationError>>(emptySet()) mutableStateOf<Set<ZecSendExt.ZecSendValidation.Invalid.ValidationError>>(emptySet())
} }
if (sendArgumentsWrapper?.recipientAddress != null) {
recipientAddressString = sendArgumentsWrapper.recipientAddress
}
if (sendArgumentsWrapper?.amount != null) {
amountZecString = sendArgumentsWrapper.amount
}
if (sendArgumentsWrapper?.memo != null) {
memoString = sendArgumentsWrapper.memo
}
Column( Column(
modifier modifier
.fillMaxHeight() .fillMaxHeight()
@ -236,6 +266,26 @@ private fun SendForm(
Spacer(modifier = Modifier.height(dimens.spacingLarge)) Spacer(modifier = Modifier.height(dimens.spacingLarge))
FormTextField(
value = recipientAddressString,
onValueChange = { recipientAddressString = it },
label = { Text(stringResource(id = R.string.send_to)) },
modifier = Modifier.fillMaxWidth(),
trailingIcon = if (hasCameraFeature) { {
IconButton(
onClick = onQrScannerOpen,
content = {
Icon(
imageVector = Icons.Outlined.QrCodeScanner,
contentDescription = stringResource(R.string.send_scan_content_description)
)
}
)
} } else { null }
)
Spacer(Modifier.size(dimens.spacingSmall))
FormTextField( FormTextField(
value = amountZecString, value = amountZecString,
onValueChange = { newValue -> onValueChange = { newValue ->
@ -251,15 +301,6 @@ private fun SendForm(
Spacer(Modifier.size(dimens.spacingSmall)) Spacer(Modifier.size(dimens.spacingSmall))
FormTextField(
value = recipientAddressString,
onValueChange = { recipientAddressString = it },
label = { Text(stringResource(id = R.string.send_to)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.size(dimens.spacingSmall))
// TODO [#810]: Disable Memo UI field in case of Transparent address // TODO [#810]: Disable Memo UI field in case of Transparent address
// TODO [#810]: https://github.com/zcash/secant-android-wallet/issues/810 // TODO [#810]: https://github.com/zcash/secant-android-wallet/issues/810
FormTextField( FormTextField(

View File

@ -8,7 +8,6 @@
<string name="home_menu_about">About</string> <string name="home_menu_about">About</string>
<string name="home_menu_support">Contact support</string> <string name="home_menu_support">Contact support</string>
<string name="home_status_syncing_format" formatted="true">Syncing - <xliff:g id="synced_percent" example="50">%1$d</xliff:g>%%</string> <!-- double %% for escaping --> <string name="home_status_syncing_format" formatted="true">Syncing - <xliff:g id="synced_percent" example="50">%1$d</xliff:g>%%</string> <!-- double %% for escaping -->
<string name="home_status_syncing_catchup">Syncing</string> <string name="home_status_syncing_catchup">Syncing</string>
<string name="home_status_syncing_amount_suffix" formatted="true"><xliff:g id="amount_prefix" example="123$">%1$s</xliff:g> so far</string> <string name="home_status_syncing_amount_suffix" formatted="true"><xliff:g id="amount_prefix" example="123$">%1$s</xliff:g> so far</string>

View File

@ -4,7 +4,7 @@
<string name="scan_back_content_description">Back</string> <string name="scan_back_content_description">Back</string>
<string name="scan_preview_content_description">Camera</string> <string name="scan_preview_content_description">Camera</string>
<string name="scan_hint">We will validate any Zcash URI and take you to the appropriate action.</string> <string name="scan_hint">You can scan any Zcash QR code.</string>
<string name="scan_settings_button">Enable camera permission</string> <string name="scan_settings_button">Enable camera permission</string>
<string name="scan_settings_open_failed">Unable to launch Settings app.</string> <string name="scan_settings_open_failed">Unable to launch Settings app.</string>
@ -15,4 +15,6 @@
<string name="scan_state_permission">Permission for camera is necessary.</string> <string name="scan_state_permission">Permission for camera is necessary.</string>
<string name="scan_state_scanning">Scanning…</string> <string name="scan_state_scanning">Scanning…</string>
<string name="scan_state_failed">Scanning failed.</string> <string name="scan_state_failed">Scanning failed.</string>
<string name="scan_validation_invalid_address">Invalid Zcash address scanned.</string>
</resources> </resources>