Implements a basic scaffold for sending ZEC. Some new model objects and an extension method were added that I hope to move to the SDK. Followup issues include - Press-and-hold for the Send button #249 - Prevent illegal input in input form #218 - Prevent undefined behavior if locale changes #217 - Add error handling to the Zec send screen #250 - Add confirmation after send is created #252
This commit is contained in:
parent
842fcd1574
commit
3d4f9fb4b4
|
@ -23,6 +23,8 @@ import androidx.test.rule.GrantPermissionRule
|
||||||
import androidx.test.runner.screenshot.Screenshot
|
import androidx.test.runner.screenshot.Screenshot
|
||||||
import cash.z.ecc.app.test.EccScreenCaptureProcessor
|
import cash.z.ecc.app.test.EccScreenCaptureProcessor
|
||||||
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
|
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
|
||||||
|
import cash.z.ecc.sdk.fixture.WalletAddressFixture
|
||||||
|
import cash.z.ecc.sdk.model.MonetarySeparators
|
||||||
import cash.z.ecc.ui.MainActivity
|
import cash.z.ecc.ui.MainActivity
|
||||||
import cash.z.ecc.ui.R
|
import cash.z.ecc.ui.R
|
||||||
import cash.z.ecc.ui.screen.backup.BackupTag
|
import cash.z.ecc.ui.screen.backup.BackupTag
|
||||||
|
@ -158,20 +160,33 @@ class ScreenshotTest {
|
||||||
it.assertExists()
|
it.assertExists()
|
||||||
it.performClick()
|
it.performClick()
|
||||||
}
|
}
|
||||||
|
// Back to home
|
||||||
// Back to home screen
|
|
||||||
composeTestRule.onNode(hasContentDescription(getStringResource(R.string.settings_back_content_description))).also {
|
composeTestRule.onNode(hasContentDescription(getStringResource(R.string.settings_back_content_description))).also {
|
||||||
it.assertExists()
|
it.assertExists()
|
||||||
it.performClick()
|
it.performClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.secretState.value is SecretState.Ready }
|
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.secretState.value is SecretState.Ready }
|
||||||
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.walletSnapshot.value != null }
|
|
||||||
composeTestRule.onNode(hasText(getStringResource(R.string.home_button_request))).also {
|
composeTestRule.onNode(hasText(getStringResource(R.string.home_button_request))).also {
|
||||||
it.assertExists()
|
it.assertExists()
|
||||||
it.performClick()
|
it.performClick()
|
||||||
}
|
}
|
||||||
|
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.walletSnapshot.value != null }
|
||||||
requestZecScreenshots(composeTestRule)
|
requestZecScreenshots(composeTestRule)
|
||||||
|
|
||||||
|
navigateTo(MainActivity.NAV_HOME)
|
||||||
|
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.secretState.value is SecretState.Ready }
|
||||||
|
|
||||||
|
composeTestRule.onNode(hasText(getStringResource(R.string.home_button_send))).also {
|
||||||
|
it.assertExists()
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.synchronizer.value != null }
|
||||||
|
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.spendingKey.value != null }
|
||||||
|
composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.walletSnapshot.value != null }
|
||||||
|
sendZecScreenshots(composeTestRule)
|
||||||
|
|
||||||
|
navigateTo(MainActivity.NAV_HOME)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -350,3 +365,29 @@ private fun requestZecScreenshots(composeTestRule: ComposeTestRule) {
|
||||||
|
|
||||||
ScreenshotTest.takeScreenshot("Request 1")
|
ScreenshotTest.takeScreenshot("Request 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sendZecScreenshots(composeTestRule: ComposeTestRule) {
|
||||||
|
composeTestRule.onNode(hasText(getStringResource(R.string.send_title))).also {
|
||||||
|
it.assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenshotTest.takeScreenshot("Send 1")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.send_amount)).also {
|
||||||
|
val separators = MonetarySeparators.current()
|
||||||
|
|
||||||
|
it.performTextInput("{${separators.decimal}}123")
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.send_to)).also {
|
||||||
|
it.performTextInput(WalletAddressFixture.UNIFIED_ADDRESS_STRING)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.send_create)).also {
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
ScreenshotTest.takeScreenshot("Send 2")
|
||||||
|
}
|
||||||
|
|
|
@ -5,13 +5,14 @@ _Note: This document will continue to be updated as the app is implemented._
|
||||||
* Versions are declared in [gradle.properties](../gradle.properties). There's still enough inconsistency in how versions are handled in Gradle, that this is as close as we can get to a universal system. A version catalog is used for dependencies and is configured in [settings.gradle.kts](../settings.gradle.kts), but other versions like Gradle Plug-ins, the NDK version, Java version, and Android SDK versions don't fit into the version catalog model and are read directly from the properties
|
* Versions are declared in [gradle.properties](../gradle.properties). There's still enough inconsistency in how versions are handled in Gradle, that this is as close as we can get to a universal system. A version catalog is used for dependencies and is configured in [settings.gradle.kts](../settings.gradle.kts), but other versions like Gradle Plug-ins, the NDK version, Java version, and Android SDK versions don't fit into the version catalog model and are read directly from the properties
|
||||||
* Much of the Gradle configuration lives in [build-conventions](../build-conventions/) to prevent repetitive configuration as additional modules are added to the project
|
* Much of the Gradle configuration lives in [build-conventions](../build-conventions/) to prevent repetitive configuration as additional modules are added to the project
|
||||||
* Build scripts are written in Kotlin, so that a single language is used across build and the app code bases
|
* Build scripts are written in Kotlin, so that a single language is used across build and the app code bases
|
||||||
* Only Gradle, Google, and JetBrains plug-ins are included in the critical path. Third party plug-ins can be used, but they're outside the critical path. For example, the Gradle Versions Plugin could be removed and wouldn't negative impact building, testing, or deploying the app
|
* Only Gradle, Google, and JetBrains plug-ins are included in the critical path. Third party plug-ins can be used, but they're outside the critical path. For example, the Gradle Versions Plugin could be removed and wouldn't negatively impact local building, testing, or releasing the app
|
||||||
* Repository restrictions are enabled in [build-conventions](../build-conventions/settings.gradle.kts), [settings.gradle.kts](../settings.gradle.kts), and [build.gradle.kts](../build.gradle.kts) to reduce likelihood of pulling in an incorrect dependency. If adding a new dependency, these restrictions may need to be changed otherwise an error that the dependency cannot be found will be displayed
|
* Repository restrictions are enabled in [build-conventions](../build-conventions/settings.gradle.kts), [settings.gradle.kts](../settings.gradle.kts), and [build.gradle.kts](../build.gradle.kts) to reduce likelihood of pulling in an incorrect dependency. If adding a new dependency, these restrictions may need to be changed otherwise an error that the dependency cannot be found will be displayed
|
||||||
|
|
||||||
# Multiplatform
|
# Multiplatform
|
||||||
While this repository is for an Android application, efforts are made to give multiplatform flexibility in the future. Specific adaptions that are being made:
|
While this repository is for an Android application, efforts are made to give multiplatform flexibility in the future. Specific adaptions that are being made:
|
||||||
* Where possible, common code is extracted into multiplatform modules
|
* Where possible, common code is extracted into multiplatform modules
|
||||||
* In UI state management code, Kotlin Flow is often preferred over Android LiveData and Compose State to grant future flexibility
|
* In UI state management code, Kotlin Flow is often preferred over Android LiveData and Compose State to grant future flexibility
|
||||||
|
* Saver is preferred over @Parcelize for objects in the SDK
|
||||||
|
|
||||||
Note: test coverage for multiplatform modules behaves differently than coverage for Android modules. Coverage is only generated for a JVM target, and requires running two tasks in sequence: `./gradlew check -PIS_COVERAGE_ENABLED=true; ./gradlew jacocoTestReport -PIS_COVERAGE_ENABLED=true`
|
Note: test coverage for multiplatform modules behaves differently than coverage for Android modules. Coverage is only generated for a JVM target, and requires running two tasks in sequence: `./gradlew check -PIS_COVERAGE_ENABLED=true; ./gradlew jacocoTestReport -PIS_COVERAGE_ENABLED=true`
|
||||||
|
|
||||||
|
@ -20,7 +21,7 @@ The main entrypoints of the application are:
|
||||||
* [AppImpl.kt](../app/src/main/java/cash/z/ecc/app/AppImpl.kt) - The root Application object defined in the app module
|
* [AppImpl.kt](../app/src/main/java/cash/z/ecc/app/AppImpl.kt) - The root Application object defined in the app module
|
||||||
* [MainActivity.kt](../ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt) - The main Activity, defined in ui-lib. Note that the Activity is NOT exported. Instead, the app module defines an activity-alias in the AndroidManifest which is what presents the actual icon on the Android home screen.
|
* [MainActivity.kt](../ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt) - The main Activity, defined in ui-lib. Note that the Activity is NOT exported. Instead, the app module defines an activity-alias in the AndroidManifest which is what presents the actual icon on the Android home screen.
|
||||||
|
|
||||||
## Modules
|
# Modules
|
||||||
The logical components of the app are implemented as a number of Gradle modules.
|
The logical components of the app are implemented as a number of Gradle modules.
|
||||||
|
|
||||||
* `app` — Compiles all of the modules together into the final application. This module contains minimal actual code. Note that the Java package structure for this module is under `cash.z.ecc.app` while the Android package name is `cash.z.ecc`.
|
* `app` — Compiles all of the modules together into the final application. This module contains minimal actual code. Note that the Java package structure for this module is under `cash.z.ecc.app` while the Android package name is `cash.z.ecc`.
|
||||||
|
@ -34,7 +35,10 @@ The logical components of the app are implemented as a number of Gradle modules.
|
||||||
* `sdk-ext-lib` — Contains extensions on top of the to the Zcash SDK. Some of these extensions might be migrated into the SDK eventually, while others might represent Android-centric idioms. Depending on how this module evolves, it could adopt another name such as `wallet-lib` or be split into two.
|
* `sdk-ext-lib` — Contains extensions on top of the to the Zcash SDK. Some of these extensions might be migrated into the SDK eventually, while others might represent Android-centric idioms. Depending on how this module evolves, it could adopt another name such as `wallet-lib` or be split into two.
|
||||||
* `spackle-lib` — Random utilities, to fill in the cracks in the Kotlin and Android frameworks.
|
* `spackle-lib` — Random utilities, to fill in the cracks in the Kotlin and Android frameworks.
|
||||||
|
|
||||||
## Shared Resources
|
# Test Fixtures
|
||||||
|
Until the Kotlin adopts support for fixtures, fixtures live within the main source modules. These fixtures make it easy to write automated tests, as well as create Compose previews. Although these fixtures are compiled into the main application, they should be removed by R8 in release builds.
|
||||||
|
|
||||||
|
# Shared Resources
|
||||||
There are some app-wide resources that share a common namespace, and these should be documented here to make it easy to ensure there are no collisions.
|
There are some app-wide resources that share a common namespace, and these should be documented here to make it easy to ensure there are no collisions.
|
||||||
|
|
||||||
* SharedPreferences
|
* SharedPreferences
|
||||||
|
|
|
@ -17,8 +17,8 @@ dependencies {
|
||||||
implementation(libs.kotlin.stdlib)
|
implementation(libs.kotlin.stdlib)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.zcash.sdk)
|
api(libs.zcash.sdk)
|
||||||
implementation(libs.zcash.bip39)
|
api(libs.zcash.bip39)
|
||||||
|
|
||||||
androidTestImplementation(libs.bundles.androidx.test)
|
androidTestImplementation(libs.bundles.androidx.test)
|
||||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
package cash.z.ecc.sdk.ext
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class StringExtTest {
|
||||||
|
@Test
|
||||||
|
fun sizeInBytes_empty() {
|
||||||
|
assertEquals(0, "".sizeInUtf8Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sizeInBytes_one() {
|
||||||
|
assertEquals(1, "a".sizeInUtf8Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sizeInBytes_unicode() {
|
||||||
|
assertEquals(2, "á".sizeInUtf8Bytes())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package cash.z.ecc.sdk.model
|
||||||
|
|
||||||
|
import cash.z.ecc.sdk.fixture.ZecSendFixture
|
||||||
|
import java.lang.IllegalArgumentException
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class MemoTest {
|
||||||
|
companion object {
|
||||||
|
private const val BYTE_STRING_513 = """
|
||||||
|
asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfa
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isWithinMaxSize_too_big() {
|
||||||
|
assertFalse(Memo.isWithinMaxLength(BYTE_STRING_513))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isWithinMaxSize_ok() {
|
||||||
|
assertTrue(Memo.isWithinMaxLength(ZecSendFixture.MEMO.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(IllegalArgumentException::class)
|
||||||
|
fun init_max_size() {
|
||||||
|
Memo(BYTE_STRING_513)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
package cash.z.ecc.sdk.model
|
||||||
|
|
||||||
|
import cash.z.ecc.sdk.fixture.WalletAddressFixture
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class WalletAddressTest {
|
||||||
|
@Test
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
fun unified_equals_different_instance() = runTest {
|
||||||
|
val one = WalletAddressFixture.unified()
|
||||||
|
val two = WalletAddressFixture.unified()
|
||||||
|
|
||||||
|
assertEquals(one, two)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package cash.z.ecc.sdk
|
||||||
|
|
||||||
|
import cash.z.ecc.android.sdk.Synchronizer
|
||||||
|
import cash.z.ecc.sdk.model.ZecSend
|
||||||
|
|
||||||
|
fun Synchronizer.send(spendingKey: String, send: ZecSend) = sendToAddress(
|
||||||
|
spendingKey,
|
||||||
|
send.amount.value,
|
||||||
|
send.destination.address,
|
||||||
|
send.memo.value
|
||||||
|
)
|
|
@ -0,0 +1,7 @@
|
||||||
|
package cash.z.ecc.sdk.ext
|
||||||
|
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
|
private val UTF_8 = Charset.forName("UTF-8")
|
||||||
|
|
||||||
|
fun String.sizeInUtf8Bytes() = toByteArray(UTF_8).size
|
|
@ -0,0 +1,9 @@
|
||||||
|
package cash.z.ecc.sdk.fixture
|
||||||
|
|
||||||
|
import cash.z.ecc.sdk.model.Memo
|
||||||
|
|
||||||
|
object MemoFixture {
|
||||||
|
const val MEMO_STRING = "Thanks for lunch"
|
||||||
|
|
||||||
|
fun new(memoString: String = MEMO_STRING) = Memo(memoString)
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package cash.z.ecc.sdk.fixture
|
||||||
|
|
||||||
|
import cash.z.ecc.sdk.model.Zatoshi
|
||||||
|
|
||||||
|
object ZatoshiFixture {
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
const val ZATOSHI_LONG = 123456789L
|
||||||
|
|
||||||
|
fun new(value: Long = ZATOSHI_LONG) = Zatoshi(value)
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
package cash.z.ecc.sdk.fixture
|
||||||
|
|
||||||
|
import cash.z.ecc.sdk.model.Memo
|
||||||
|
import cash.z.ecc.sdk.model.WalletAddress
|
||||||
|
import cash.z.ecc.sdk.model.Zatoshi
|
||||||
|
import cash.z.ecc.sdk.model.ZecSend
|
||||||
|
|
||||||
|
object ZecSendFixture {
|
||||||
|
const val ADDRESS: String = WalletAddressFixture.UNIFIED_ADDRESS_STRING
|
||||||
|
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
val AMOUNT = Zatoshi(123)
|
||||||
|
val MEMO = MemoFixture.new()
|
||||||
|
|
||||||
|
suspend fun new(
|
||||||
|
address: String = ADDRESS,
|
||||||
|
amount: Zatoshi = AMOUNT,
|
||||||
|
message: Memo = MEMO
|
||||||
|
) = ZecSend(WalletAddress.Unified.new(address), amount, message)
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
package cash.z.ecc.sdk.model
|
||||||
|
|
||||||
|
import cash.z.ecc.sdk.ext.sizeInUtf8Bytes
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class Memo(val value: String) {
|
||||||
|
init {
|
||||||
|
require(isWithinMaxLength(value)) {
|
||||||
|
"Memo length in bytes must be less than $MAX_MEMO_LENGTH_BYTES but " +
|
||||||
|
"actually has length ${value.sizeInUtf8Bytes()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The decoded memo contents MUST NOT exceed 512 bytes.
|
||||||
|
*
|
||||||
|
* https://zips.z.cash/zip-0321
|
||||||
|
*/
|
||||||
|
private const val MAX_MEMO_LENGTH_BYTES = 512
|
||||||
|
|
||||||
|
fun isWithinMaxLength(memoString: String) = memoString.sizeInUtf8Bytes() <= MAX_MEMO_LENGTH_BYTES
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,9 +4,9 @@ package cash.z.ecc.sdk.model
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@JvmInline
|
@JvmInline
|
||||||
value class Zatoshi(val amount: Long) {
|
value class Zatoshi(val value: Long) {
|
||||||
init {
|
init {
|
||||||
require(amount >= 0)
|
require(value >= 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object
|
companion object
|
||||||
|
|
|
@ -21,9 +21,9 @@ data class ZecRequest(val address: WalletAddress.Unified, val amount: Zatoshi, v
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmInline
|
@JvmInline
|
||||||
value class ZecRequestMessage(val message: String) {
|
value class ZecRequestMessage(val value: String) {
|
||||||
init {
|
init {
|
||||||
require(message.length <= MAX_MESSAGE_LENGTH)
|
require(value.length <= MAX_MESSAGE_LENGTH)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
package cash.z.ecc.sdk.model
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
data class ZecSend(val destination: WalletAddress, val amount: Zatoshi, val memo: Memo) {
|
||||||
|
companion object
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ZecSend.Companion.new(
|
||||||
|
destinationString: String,
|
||||||
|
zecString: String,
|
||||||
|
memoString: String,
|
||||||
|
monetarySeparators: MonetarySeparators
|
||||||
|
): ZecSendValidation {
|
||||||
|
|
||||||
|
// This runBlocking shouldn't have a performance impact, since everything needs to be loaded at this point.
|
||||||
|
// However it would be better to eliminate it entirely.
|
||||||
|
val destination = runBlocking { WalletAddress.Unified.new(destinationString) }
|
||||||
|
val amount = Zatoshi.fromZecString(zecString, monetarySeparators)
|
||||||
|
val memo = Memo(memoString)
|
||||||
|
|
||||||
|
val validationErrors = buildSet {
|
||||||
|
if (null == amount) {
|
||||||
|
add(ZecSendValidation.Invalid.ValidationError.INVALID_AMOUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO [#250]: Implement all validation
|
||||||
|
// TODO [#342]: https://github.com/zcash/zcash-android-wallet-sdk/issues/342
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (validationErrors.isEmpty()) {
|
||||||
|
ZecSendValidation.Valid(ZecSend(destination, amount!!, memo))
|
||||||
|
} else {
|
||||||
|
ZecSendValidation.Invalid(validationErrors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ZecSendValidation {
|
||||||
|
data class Valid(val zecSend: ZecSend) : ZecSendValidation()
|
||||||
|
data class Invalid(val validationErrors: Set<ValidationError>) : ZecSendValidation() {
|
||||||
|
enum class ValidationError {
|
||||||
|
INVALID_ADDRESS,
|
||||||
|
INVALID_AMOUNT,
|
||||||
|
INVALID_MEMO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -52,7 +52,7 @@ private const val DECIMALS = 8
|
||||||
|
|
||||||
// TODO [#412]: https://github.com/zcash/zcash-android-wallet-sdk/issues/412
|
// TODO [#412]: https://github.com/zcash/zcash-android-wallet-sdk/issues/412
|
||||||
// The SDK needs to fix the API for currency conversion
|
// The SDK needs to fix the API for currency conversion
|
||||||
fun Zatoshi.toZecString() = amount.convertZatoshiToZecString(DECIMALS, DECIMALS)
|
fun Zatoshi.toZecString() = value.convertZatoshiToZecString(DECIMALS, DECIMALS)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ZEC is our own currency, so there's not going to be an existing localization that matches it perfectly.
|
* ZEC is our own currency, so there's not going to be an existing localization that matches it perfectly.
|
||||||
|
|
|
@ -34,6 +34,7 @@ android {
|
||||||
"src/main/res/ui/restore",
|
"src/main/res/ui/restore",
|
||||||
"src/main/res/ui/request",
|
"src/main/res/ui/request",
|
||||||
"src/main/res/ui/seed",
|
"src/main/res/ui/seed",
|
||||||
|
"src/main/res/ui/send",
|
||||||
"src/main/res/ui/settings",
|
"src/main/res/ui/settings",
|
||||||
"src/main/res/ui/wallet_address"
|
"src/main/res/ui/wallet_address"
|
||||||
)
|
)
|
||||||
|
|
|
@ -21,6 +21,7 @@ import cash.z.ecc.ui.theme.ZcashTheme
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
class BackupViewTest {
|
class BackupViewTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
|
@ -209,18 +210,18 @@ class BackupViewTest {
|
||||||
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: BackupStage) {
|
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: BackupStage) {
|
||||||
private val state = BackupState(initalStage)
|
private val state = BackupState(initalStage)
|
||||||
|
|
||||||
private var onCopyToClipboardCount = 0
|
private val onCopyToClipboardCount = AtomicInteger(0)
|
||||||
|
|
||||||
private var onCompleteCallbackCount = 0
|
private val onCompleteCallbackCount = AtomicInteger(0)
|
||||||
|
|
||||||
fun getOnCopyToClipboardCount(): Int {
|
fun getOnCopyToClipboardCount(): Int {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return onCopyToClipboardCount
|
return onCopyToClipboardCount.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOnCompleteCallbackCount(): Int {
|
fun getOnCompleteCallbackCount(): Int {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return onCompleteCallbackCount
|
return onCompleteCallbackCount.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStage(): BackupStage {
|
fun getStage(): BackupStage {
|
||||||
|
@ -235,8 +236,8 @@ class BackupViewTest {
|
||||||
PersistableWalletFixture.new(),
|
PersistableWalletFixture.new(),
|
||||||
state,
|
state,
|
||||||
TestChoices(),
|
TestChoices(),
|
||||||
onCopyToClipboard = { onCopyToClipboardCount++ },
|
onCopyToClipboard = { onCopyToClipboardCount.incrementAndGet() },
|
||||||
onComplete = { onCompleteCallbackCount++ }
|
onComplete = { onCompleteCallbackCount.incrementAndGet() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import cash.z.ecc.ui.theme.ZcashTheme
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
class OnboardingViewTest {
|
class OnboardingViewTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
|
@ -243,17 +244,17 @@ class OnboardingViewTest {
|
||||||
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: OnboardingStage) {
|
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: OnboardingStage) {
|
||||||
private val onboardingState = OnboardingState(initalStage)
|
private val onboardingState = OnboardingState(initalStage)
|
||||||
|
|
||||||
private var onCreateWalletCallbackCount = 0
|
private val onCreateWalletCallbackCount = AtomicInteger(0)
|
||||||
private var onImportWalletCallbackCount = 0
|
private val onImportWalletCallbackCount = AtomicInteger(0)
|
||||||
|
|
||||||
fun getOnCreateWalletCallbackCount(): Int {
|
fun getOnCreateWalletCallbackCount(): Int {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return onCreateWalletCallbackCount
|
return onCreateWalletCallbackCount.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOnImportWalletCallbackCount(): Int {
|
fun getOnImportWalletCallbackCount(): Int {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return onImportWalletCallbackCount
|
return onImportWalletCallbackCount.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOnboardingStage(): OnboardingStage {
|
fun getOnboardingStage(): OnboardingStage {
|
||||||
|
@ -266,8 +267,8 @@ class OnboardingViewTest {
|
||||||
ZcashTheme {
|
ZcashTheme {
|
||||||
Onboarding(
|
Onboarding(
|
||||||
onboardingState,
|
onboardingState,
|
||||||
onCreateWallet = { onCreateWalletCallbackCount++ },
|
onCreateWallet = { onCreateWalletCallbackCount.incrementAndGet() },
|
||||||
onImportWallet = { onImportWalletCallbackCount++ }
|
onImportWallet = { onImportWalletCallbackCount.incrementAndGet() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,7 +68,7 @@ class RequestViewTest {
|
||||||
assertNotNull(it)
|
assertNotNull(it)
|
||||||
assertEquals(WalletAddressFixture.unified(), it.address)
|
assertEquals(WalletAddressFixture.unified(), it.address)
|
||||||
assertEquals(Zatoshi(12300000), it.amount)
|
assertEquals(Zatoshi(12300000), it.amount)
|
||||||
assertTrue(it.message.message.isEmpty())
|
assertTrue(it.message.value.isEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,7 +88,7 @@ class RequestViewTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(getStringResource(R.string.request_message)).also {
|
composeTestRule.onNodeWithText(getStringResource(R.string.request_message)).also {
|
||||||
it.performTextInput(ZecRequestFixture.MESSAGE.message)
|
it.performTextInput(ZecRequestFixture.MESSAGE.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(getStringResource(R.string.request_create)).also {
|
composeTestRule.onNodeWithText(getStringResource(R.string.request_create)).also {
|
||||||
|
@ -101,7 +101,7 @@ class RequestViewTest {
|
||||||
assertNotNull(it)
|
assertNotNull(it)
|
||||||
assertEquals(WalletAddressFixture.unified(), it.address)
|
assertEquals(WalletAddressFixture.unified(), it.address)
|
||||||
assertEquals(Zatoshi(12300000), it.amount)
|
assertEquals(Zatoshi(12300000), it.amount)
|
||||||
assertEquals(ZecRequestFixture.MESSAGE.message, it.message.message)
|
assertEquals(ZecRequestFixture.MESSAGE.value, it.message.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,7 +159,7 @@ class RequestViewTest {
|
||||||
assertNotNull(it)
|
assertNotNull(it)
|
||||||
assertEquals(WalletAddressFixture.unified(), it.address)
|
assertEquals(WalletAddressFixture.unified(), it.address)
|
||||||
assertEquals(Zatoshi(12300000), it.amount)
|
assertEquals(Zatoshi(12300000), it.amount)
|
||||||
assertTrue(it.message.message.isEmpty())
|
assertTrue(it.message.value.isEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
class RestoreViewTest {
|
class RestoreViewTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
|
@ -194,9 +195,9 @@ class RestoreViewTest {
|
||||||
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: List<String>) {
|
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: List<String>) {
|
||||||
private val state = WordList(initialState)
|
private val state = WordList(initialState)
|
||||||
|
|
||||||
private var onBackCount = 0
|
private val onBackCount = AtomicInteger(0)
|
||||||
|
|
||||||
private var onFinishedCount = 0
|
private val onFinishedCount = AtomicInteger(0)
|
||||||
|
|
||||||
fun getUserInputWords(): List<String> {
|
fun getUserInputWords(): List<String> {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
@ -205,12 +206,12 @@ class RestoreViewTest {
|
||||||
|
|
||||||
fun getOnBackCount(): Int {
|
fun getOnBackCount(): Int {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return onBackCount
|
return onBackCount.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOnFinishedCount(): Int {
|
fun getOnFinishedCount(): Int {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return onFinishedCount
|
return onFinishedCount.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -220,11 +221,11 @@ class RestoreViewTest {
|
||||||
Mnemonics.getCachedWords(Locale.ENGLISH.language).toSortedSet(),
|
Mnemonics.getCachedWords(Locale.ENGLISH.language).toSortedSet(),
|
||||||
state,
|
state,
|
||||||
onBack = {
|
onBack = {
|
||||||
onBackCount++
|
onBackCount.incrementAndGet()
|
||||||
},
|
},
|
||||||
paste = { "" },
|
paste = { "" },
|
||||||
onFinished = {
|
onFinished = {
|
||||||
onFinishedCount++
|
onFinishedCount.incrementAndGet()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
package cash.z.ecc.ui.screen.send.ext
|
||||||
|
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.filters.SmallTest
|
||||||
|
import cash.z.ecc.sdk.fixture.WalletAddressFixture
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class WalletAddressExtTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SmallTest
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun testAbbreviated() = runTest {
|
||||||
|
val actual = WalletAddressFixture.shieldedSapling().abbreviated(ApplicationProvider.getApplicationContext())
|
||||||
|
|
||||||
|
// TODO [#248]: The expected value should probably be reversed if the locale is RTL
|
||||||
|
val expected = "ztest…rxnwg"
|
||||||
|
|
||||||
|
assertEquals(expected, actual)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package cash.z.ecc.ui.screen.send.ext
|
||||||
|
|
||||||
|
import androidx.compose.runtime.saveable.SaverScope
|
||||||
|
import androidx.test.filters.SmallTest
|
||||||
|
import cash.z.ecc.sdk.fixture.ZecSendFixture
|
||||||
|
import cash.z.ecc.sdk.model.ZecSend
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class ZecSendExtTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SmallTest
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun round_trip() = runTest {
|
||||||
|
val original = ZecSendFixture.new()
|
||||||
|
val saved = with(ZecSend.Saver) {
|
||||||
|
val allowingScope = SaverScope { true }
|
||||||
|
|
||||||
|
allowingScope.save(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
val restored = ZecSend.Saver.restore(saved!!)
|
||||||
|
|
||||||
|
assertEquals(original, restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SmallTest
|
||||||
|
fun restore_empty() {
|
||||||
|
val restored = ZecSend.Saver.restore(emptyList<Any?>())
|
||||||
|
assertEquals(null, restored)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,271 @@
|
||||||
|
package cash.z.ecc.ui.screen.send.view
|
||||||
|
|
||||||
|
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.performTextInput
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import cash.z.ecc.sdk.fixture.MemoFixture
|
||||||
|
import cash.z.ecc.sdk.fixture.WalletAddressFixture
|
||||||
|
import cash.z.ecc.sdk.fixture.ZatoshiFixture
|
||||||
|
import cash.z.ecc.sdk.fixture.ZecRequestFixture
|
||||||
|
import cash.z.ecc.sdk.model.Memo
|
||||||
|
import cash.z.ecc.sdk.model.MonetarySeparators
|
||||||
|
import cash.z.ecc.sdk.model.Zatoshi
|
||||||
|
import cash.z.ecc.sdk.model.ZecSend
|
||||||
|
import cash.z.ecc.ui.R
|
||||||
|
import cash.z.ecc.ui.test.getStringResource
|
||||||
|
import cash.z.ecc.ui.theme.ZcashTheme
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Ignore
|
||||||
|
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
|
||||||
|
|
||||||
|
class SendViewTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun create_button_disabled() {
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
|
val testSetup = TestSetup(composeTestRule)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.send_create)).also {
|
||||||
|
it.assertExists()
|
||||||
|
it.assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun create_request_no_memo() = runTest {
|
||||||
|
val testSetup = TestSetup(composeTestRule)
|
||||||
|
|
||||||
|
assertEquals(0, testSetup.getOnCreateCount())
|
||||||
|
assertEquals(null, testSetup.getLastSend())
|
||||||
|
|
||||||
|
composeTestRule.setValidAmount()
|
||||||
|
composeTestRule.setValidAddress()
|
||||||
|
composeTestRule.clickCreateAndSend()
|
||||||
|
composeTestRule.assertOnConfirmation()
|
||||||
|
composeTestRule.clickConfirmation()
|
||||||
|
|
||||||
|
assertEquals(1, testSetup.getOnCreateCount())
|
||||||
|
|
||||||
|
testSetup.getLastSend().also {
|
||||||
|
assertNotNull(it)
|
||||||
|
assertEquals(WalletAddressFixture.unified(), it.destination)
|
||||||
|
assertEquals(Zatoshi(12300000), it.amount)
|
||||||
|
assertTrue(it.memo.value.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun create_request_with_memo() = runTest {
|
||||||
|
val testSetup = TestSetup(composeTestRule)
|
||||||
|
|
||||||
|
assertEquals(0, testSetup.getOnCreateCount())
|
||||||
|
assertEquals(null, testSetup.getLastSend())
|
||||||
|
|
||||||
|
composeTestRule.setValidAmount()
|
||||||
|
composeTestRule.setValidAddress()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also {
|
||||||
|
it.performTextInput(MemoFixture.MEMO_STRING)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.clickCreateAndSend()
|
||||||
|
composeTestRule.assertOnConfirmation()
|
||||||
|
composeTestRule.clickConfirmation()
|
||||||
|
|
||||||
|
assertEquals(1, testSetup.getOnCreateCount())
|
||||||
|
|
||||||
|
testSetup.getLastSend().also {
|
||||||
|
assertNotNull(it)
|
||||||
|
assertEquals(WalletAddressFixture.unified(), it.destination)
|
||||||
|
assertEquals(Zatoshi(12300000), it.amount)
|
||||||
|
assertEquals(ZecRequestFixture.MESSAGE.value, it.memo.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
@Ignore("https://github.com/zcash/secant-android-wallet/issues/218")
|
||||||
|
fun create_request_illegal_amount() {
|
||||||
|
val testSetup = TestSetup(composeTestRule)
|
||||||
|
|
||||||
|
assertEquals(0, testSetup.getOnCreateCount())
|
||||||
|
assertEquals(null, testSetup.getLastSend())
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.send_amount)).also {
|
||||||
|
val separators = MonetarySeparators.current()
|
||||||
|
|
||||||
|
it.performTextInput("{${separators.decimal}}1{${separators.decimal}}2{${separators.decimal}}3{${separators.decimal}}4")
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.setValidAddress()
|
||||||
|
|
||||||
|
composeTestRule.clickCreateAndSend()
|
||||||
|
|
||||||
|
assertEquals(0, testSetup.getOnCreateCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun max_memo_length() = runTest {
|
||||||
|
val testSetup = TestSetup(composeTestRule)
|
||||||
|
|
||||||
|
composeTestRule.setValidAmount()
|
||||||
|
composeTestRule.setValidAddress()
|
||||||
|
|
||||||
|
val input = buildString {
|
||||||
|
while (Memo.isWithinMaxLength(toString())) {
|
||||||
|
append("a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also {
|
||||||
|
it.performTextInput(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.clickCreateAndSend()
|
||||||
|
composeTestRule.assertOnConfirmation()
|
||||||
|
composeTestRule.clickConfirmation()
|
||||||
|
|
||||||
|
assertEquals(1, testSetup.getOnCreateCount())
|
||||||
|
|
||||||
|
testSetup.getLastSend().also {
|
||||||
|
assertNotNull(it)
|
||||||
|
assertEquals(WalletAddressFixture.unified(), it.destination)
|
||||||
|
assertEquals(Zatoshi(12300000), it.amount)
|
||||||
|
assertTrue(it.memo.value.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun back_on_form() {
|
||||||
|
val testSetup = TestSetup(composeTestRule)
|
||||||
|
|
||||||
|
assertEquals(0, testSetup.getOnBackCount())
|
||||||
|
|
||||||
|
composeTestRule.clickBack()
|
||||||
|
|
||||||
|
assertEquals(1, testSetup.getOnBackCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MediumTest
|
||||||
|
fun back_on_confirmation() {
|
||||||
|
val testSetup = TestSetup(composeTestRule)
|
||||||
|
|
||||||
|
assertEquals(0, testSetup.getOnBackCount())
|
||||||
|
|
||||||
|
composeTestRule.setValidAmount()
|
||||||
|
composeTestRule.setValidAddress()
|
||||||
|
composeTestRule.clickCreateAndSend()
|
||||||
|
composeTestRule.assertOnConfirmation()
|
||||||
|
composeTestRule.clickBack()
|
||||||
|
composeTestRule.assertOnForm()
|
||||||
|
|
||||||
|
assertEquals(0, testSetup.getOnBackCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestSetup(private val composeTestRule: ComposeContentTestRule) {
|
||||||
|
|
||||||
|
private val onBackCount = AtomicInteger(0)
|
||||||
|
private val onCreateCount = AtomicInteger(0)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var onSendZecRequest: ZecSend? = null
|
||||||
|
|
||||||
|
fun getOnBackCount(): Int {
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
return onBackCount.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getOnCreateCount(): Int {
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
return onCreateCount.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLastSend(): ZecSend? {
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
return onSendZecRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
ZcashTheme {
|
||||||
|
Send(
|
||||||
|
mySpendableBalance = ZatoshiFixture.new(),
|
||||||
|
goBack = {
|
||||||
|
onBackCount.incrementAndGet()
|
||||||
|
},
|
||||||
|
onCreateAndSend = {
|
||||||
|
onCreateCount.incrementAndGet()
|
||||||
|
onSendZecRequest = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.performTextInput("{${separators.decimal}}123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ComposeContentTestRule.setValidAddress() {
|
||||||
|
onNodeWithText(getStringResource(R.string.send_to)).also {
|
||||||
|
it.performTextInput(WalletAddressFixture.UNIFIED_ADDRESS_STRING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,7 @@ import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class WalletAddressViewTest {
|
class WalletAddressViewTest {
|
||||||
|
@ -154,11 +155,11 @@ class WalletAddressViewTest {
|
||||||
|
|
||||||
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: WalletAddresses) {
|
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: WalletAddresses) {
|
||||||
|
|
||||||
private var onBackCount = 0
|
private val onBackCount = AtomicInteger(0)
|
||||||
|
|
||||||
fun getOnBackCount(): Int {
|
fun getOnBackCount(): Int {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return onBackCount
|
return onBackCount.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -167,7 +168,7 @@ class WalletAddressViewTest {
|
||||||
WalletAddresses(
|
WalletAddresses(
|
||||||
initialState,
|
initialState,
|
||||||
onBack = {
|
onBack = {
|
||||||
onBackCount++
|
onBackCount.incrementAndGet()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,11 +26,13 @@ import cash.z.ecc.android.sdk.type.ZcashNetwork
|
||||||
import cash.z.ecc.sdk.model.PersistableWallet
|
import cash.z.ecc.sdk.model.PersistableWallet
|
||||||
import cash.z.ecc.sdk.model.SeedPhrase
|
import cash.z.ecc.sdk.model.SeedPhrase
|
||||||
import cash.z.ecc.sdk.model.ZecRequest
|
import cash.z.ecc.sdk.model.ZecRequest
|
||||||
|
import cash.z.ecc.sdk.send
|
||||||
import cash.z.ecc.sdk.type.fromResources
|
import cash.z.ecc.sdk.type.fromResources
|
||||||
import cash.z.ecc.ui.design.compat.FontCompat
|
import cash.z.ecc.ui.design.compat.FontCompat
|
||||||
import cash.z.ecc.ui.design.component.GradientSurface
|
import cash.z.ecc.ui.design.component.GradientSurface
|
||||||
import cash.z.ecc.ui.screen.backup.view.BackupWallet
|
import cash.z.ecc.ui.screen.backup.view.BackupWallet
|
||||||
import cash.z.ecc.ui.screen.backup.viewmodel.BackupViewModel
|
import cash.z.ecc.ui.screen.backup.viewmodel.BackupViewModel
|
||||||
|
import cash.z.ecc.ui.screen.home.model.spendableBalance
|
||||||
import cash.z.ecc.ui.screen.home.view.Home
|
import cash.z.ecc.ui.screen.home.view.Home
|
||||||
import cash.z.ecc.ui.screen.home.viewmodel.SecretState
|
import cash.z.ecc.ui.screen.home.viewmodel.SecretState
|
||||||
import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel
|
import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel
|
||||||
|
@ -42,6 +44,7 @@ import cash.z.ecc.ui.screen.restore.view.RestoreWallet
|
||||||
import cash.z.ecc.ui.screen.restore.viewmodel.CompleteWordSetState
|
import cash.z.ecc.ui.screen.restore.viewmodel.CompleteWordSetState
|
||||||
import cash.z.ecc.ui.screen.restore.viewmodel.RestoreViewModel
|
import cash.z.ecc.ui.screen.restore.viewmodel.RestoreViewModel
|
||||||
import cash.z.ecc.ui.screen.seed.view.Seed
|
import cash.z.ecc.ui.screen.seed.view.Seed
|
||||||
|
import cash.z.ecc.ui.screen.send.view.Send
|
||||||
import cash.z.ecc.ui.screen.settings.view.Settings
|
import cash.z.ecc.ui.screen.settings.view.Settings
|
||||||
import cash.z.ecc.ui.screen.wallet_address.view.WalletAddresses
|
import cash.z.ecc.ui.screen.wallet_address.view.WalletAddresses
|
||||||
import cash.z.ecc.ui.theme.ZcashTheme
|
import cash.z.ecc.ui.theme.ZcashTheme
|
||||||
|
@ -212,7 +215,7 @@ class MainActivity : ComponentActivity() {
|
||||||
WrapHome(
|
WrapHome(
|
||||||
goScan = {},
|
goScan = {},
|
||||||
goProfile = { navController.navigate(NAV_PROFILE) },
|
goProfile = { navController.navigate(NAV_PROFILE) },
|
||||||
goSend = {},
|
goSend = { navController.navigate(NAV_SEND) },
|
||||||
goRequest = { navController.navigate(NAV_REQUEST) }
|
goRequest = { navController.navigate(NAV_REQUEST) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -253,6 +256,9 @@ class MainActivity : ComponentActivity() {
|
||||||
composable(NAV_REQUEST) {
|
composable(NAV_REQUEST) {
|
||||||
WrapRequest(goBack = { navController.popBackStack() })
|
WrapRequest(goBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
|
composable(NAV_SEND) {
|
||||||
|
WrapSend(goBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -396,6 +402,28 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WrapSend(
|
||||||
|
goBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val synchronizer = walletViewModel.synchronizer.collectAsState().value
|
||||||
|
val spendableBalance = walletViewModel.walletSnapshot.collectAsState().value?.spendableBalance()
|
||||||
|
val spendingKey = walletViewModel.spendingKey.collectAsState().value
|
||||||
|
if (null == synchronizer || null == spendableBalance || null == spendingKey) {
|
||||||
|
// Display loading indicator
|
||||||
|
} else {
|
||||||
|
Send(
|
||||||
|
mySpendableBalance = spendableBalance,
|
||||||
|
goBack = goBack,
|
||||||
|
onCreateAndSend = {
|
||||||
|
synchronizer.send(spendingKey, it)
|
||||||
|
|
||||||
|
goBack()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
internal val SPLASH_SCREEN_DELAY = 0.seconds
|
internal val SPLASH_SCREEN_DELAY = 0.seconds
|
||||||
|
@ -417,6 +445,9 @@ class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
const val NAV_REQUEST = "request"
|
const val NAV_REQUEST = "request"
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
const val NAV_SEND = "send"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,3 +27,12 @@ fun WalletSnapshot.totalBalance(): Zatoshi {
|
||||||
|
|
||||||
return Zatoshi(total.coerceAtLeast(0))
|
return Zatoshi(total.coerceAtLeast(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun WalletSnapshot.spendableBalance(): Zatoshi {
|
||||||
|
// Note that considering both to be spendable is subject to change.
|
||||||
|
// The user experience could be confusing, and in the future we might prefer to ask users
|
||||||
|
// to transfer their balance to the latest balance type to make it spendable.
|
||||||
|
val total = (orchardBalance + saplingBalance).totalZatoshi
|
||||||
|
|
||||||
|
return Zatoshi(total.coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
|
|
@ -3,12 +3,15 @@ package cash.z.ecc.ui.screen.home.viewmodel
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import cash.z.ecc.android.bip39.Mnemonics
|
||||||
|
import cash.z.ecc.android.bip39.toSeed
|
||||||
import cash.z.ecc.android.sdk.Synchronizer
|
import cash.z.ecc.android.sdk.Synchronizer
|
||||||
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
|
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
|
||||||
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
|
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
|
||||||
import cash.z.ecc.android.sdk.db.entity.Transaction
|
import cash.z.ecc.android.sdk.db.entity.Transaction
|
||||||
import cash.z.ecc.android.sdk.db.entity.isMined
|
import cash.z.ecc.android.sdk.db.entity.isMined
|
||||||
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
|
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
|
||||||
|
import cash.z.ecc.android.sdk.tool.DerivationTool
|
||||||
import cash.z.ecc.android.sdk.type.WalletBalance
|
import cash.z.ecc.android.sdk.type.WalletBalance
|
||||||
import cash.z.ecc.global.WalletCoordinator
|
import cash.z.ecc.global.WalletCoordinator
|
||||||
import cash.z.ecc.sdk.model.PersistableWallet
|
import cash.z.ecc.sdk.model.PersistableWallet
|
||||||
|
@ -20,6 +23,7 @@ import cash.z.ecc.ui.preference.StandardPreferenceKeys
|
||||||
import cash.z.ecc.ui.preference.StandardPreferenceSingleton
|
import cash.z.ecc.ui.preference.StandardPreferenceSingleton
|
||||||
import cash.z.ecc.ui.screen.home.model.WalletSnapshot
|
import cash.z.ecc.ui.screen.home.model.WalletSnapshot
|
||||||
import cash.z.ecc.work.WorkIds
|
import cash.z.ecc.work.WorkIds
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
@ -35,6 +39,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
// To make this more multiplatform compatible, we need to remove the dependency on Context
|
// To make this more multiplatform compatible, we need to remove the dependency on Context
|
||||||
// for loading the preferences.
|
// for loading the preferences.
|
||||||
|
@ -79,6 +84,22 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
|
||||||
SecretState.Loading
|
SecretState.Loading
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// This needs to be refactored once we support pin lock
|
||||||
|
val spendingKey = secretState
|
||||||
|
.filterIsInstance<SecretState.Ready>()
|
||||||
|
.map { it.persistableWallet }
|
||||||
|
.map {
|
||||||
|
val bip39Seed = withContext(Dispatchers.IO) {
|
||||||
|
Mnemonics.MnemonicCode(it.seedPhrase.joinToString()).toSeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
DerivationTool.deriveSpendingKeys(bip39Seed, it.network)[0]
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
val walletSnapshot: StateFlow<WalletSnapshot?> = synchronizer
|
val walletSnapshot: StateFlow<WalletSnapshot?> = synchronizer
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
package cash.z.ecc.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.sdk.model.WalletAddress
|
||||||
|
import cash.z.ecc.ui.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far into the address will be abbreviation look forwards and backwards.
|
||||||
|
*/
|
||||||
|
internal const val ABBREVIATION_INDEX = 5
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
internal fun WalletAddress.abbreviated(): String {
|
||||||
|
LocalConfiguration.current
|
||||||
|
return abbreviated(LocalContext.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun WalletAddress.abbreviated(context: Context): String {
|
||||||
|
require(address.length >= ABBREVIATION_INDEX) { "Address must be at least 5 characters long" }
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
package cash.z.ecc.ui.screen.send.ext
|
||||||
|
|
||||||
|
import androidx.compose.runtime.saveable.mapSaver
|
||||||
|
import cash.z.ecc.sdk.model.Memo
|
||||||
|
import cash.z.ecc.sdk.model.WalletAddress
|
||||||
|
import cash.z.ecc.sdk.model.Zatoshi
|
||||||
|
import cash.z.ecc.sdk.model.ZecSend
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
private const val KEY_ADDRESS = "address"
|
||||||
|
private const val KEY_AMOUNT = "amount"
|
||||||
|
private const val KEY_MEMO = "memo"
|
||||||
|
|
||||||
|
// Using a custom saver instead of Parcelize, to avoid adding an Android-specific API to
|
||||||
|
// the ZecSend class
|
||||||
|
internal val ZecSend.Companion.Saver
|
||||||
|
get() = run {
|
||||||
|
mapSaver<ZecSend?>(
|
||||||
|
save = {
|
||||||
|
it?.toSaverMap() ?: emptyMap()
|
||||||
|
},
|
||||||
|
restore = {
|
||||||
|
if (it.isEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val address = runBlocking { WalletAddress.Unified.new(it[KEY_ADDRESS] as String) }
|
||||||
|
val amount = Zatoshi(it[KEY_AMOUNT] as Long)
|
||||||
|
val memo = Memo(it[KEY_MEMO] as String)
|
||||||
|
ZecSend(address, amount, memo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZecSend.toSaverMap() = buildMap {
|
||||||
|
put(KEY_ADDRESS, destination.address)
|
||||||
|
put(KEY_AMOUNT, amount.value)
|
||||||
|
put(KEY_MEMO, memo.value)
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package cash.z.ecc.ui.screen.send.model
|
||||||
|
|
||||||
|
enum class SendStage {
|
||||||
|
Form,
|
||||||
|
Confirmation
|
||||||
|
}
|
|
@ -0,0 +1,236 @@
|
||||||
|
package cash.z.ecc.ui.screen.send.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SmallTopAppBar
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import cash.z.ecc.sdk.fixture.ZatoshiFixture
|
||||||
|
import cash.z.ecc.sdk.model.Memo
|
||||||
|
import cash.z.ecc.sdk.model.MonetarySeparators
|
||||||
|
import cash.z.ecc.sdk.model.Zatoshi
|
||||||
|
import cash.z.ecc.sdk.model.ZecSend
|
||||||
|
import cash.z.ecc.sdk.model.ZecSendValidation
|
||||||
|
import cash.z.ecc.sdk.model.ZecString
|
||||||
|
import cash.z.ecc.sdk.model.new
|
||||||
|
import cash.z.ecc.sdk.model.toZecString
|
||||||
|
import cash.z.ecc.ui.R
|
||||||
|
import cash.z.ecc.ui.design.MINIMAL_WEIGHT
|
||||||
|
import cash.z.ecc.ui.design.component.GradientSurface
|
||||||
|
import cash.z.ecc.ui.design.component.PrimaryButton
|
||||||
|
import cash.z.ecc.ui.design.component.TextField
|
||||||
|
import cash.z.ecc.ui.screen.send.ext.ABBREVIATION_INDEX
|
||||||
|
import cash.z.ecc.ui.screen.send.ext.Saver
|
||||||
|
import cash.z.ecc.ui.screen.send.ext.abbreviated
|
||||||
|
import cash.z.ecc.ui.screen.send.model.SendStage
|
||||||
|
import cash.z.ecc.ui.theme.ZcashTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
fun PreviewSend() {
|
||||||
|
ZcashTheme(darkTheme = true) {
|
||||||
|
GradientSurface {
|
||||||
|
Send(
|
||||||
|
mySpendableBalance = ZatoshiFixture.new(),
|
||||||
|
goBack = {},
|
||||||
|
onCreateAndSend = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun Send(
|
||||||
|
mySpendableBalance: Zatoshi,
|
||||||
|
goBack: () -> 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
SendMainContent(
|
||||||
|
mySpendableBalance,
|
||||||
|
sendStage,
|
||||||
|
setSendStage,
|
||||||
|
onCreateAndSend = onCreateAndSend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SendTopAppBar(onBack: () -> Unit) {
|
||||||
|
SmallTopAppBar(
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SendMainContent(
|
||||||
|
myBalance: Zatoshi,
|
||||||
|
sendStage: SendStage,
|
||||||
|
setSendStage: (SendStage) -> Unit,
|
||||||
|
onCreateAndSend: (ZecSend) -> Unit
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Confirmation(zecSend) {
|
||||||
|
onCreateAndSend(zecSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button.
|
||||||
|
@Composable
|
||||||
|
private fun SendForm(
|
||||||
|
myBalance: Zatoshi,
|
||||||
|
previousZecSend: ZecSend?,
|
||||||
|
onCreateAndSend: (ZecSend) -> Unit
|
||||||
|
) {
|
||||||
|
val monetarySeparators = MonetarySeparators.current()
|
||||||
|
val allowedCharacters = ZecString.allowedCharacters(monetarySeparators)
|
||||||
|
|
||||||
|
var amountZecString by rememberSaveable {
|
||||||
|
mutableStateOf(previousZecSend?.amount?.toZecString() ?: "")
|
||||||
|
}
|
||||||
|
var recipientAddressString by rememberSaveable {
|
||||||
|
mutableStateOf(previousZecSend?.destination?.address ?: "")
|
||||||
|
}
|
||||||
|
var memoString by rememberSaveable { mutableStateOf(previousZecSend?.memo?.value ?: "") }
|
||||||
|
|
||||||
|
var validation by rememberSaveable { mutableStateOf<Set<ZecSendValidation.Invalid.ValidationError>>(emptySet()) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(text = myBalance.toZecString())
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = amountZecString,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
// TODO [#218]: this doesn't prevent illegal input. So users could still type `1.2.3.4`
|
||||||
|
amountZecString = newValue.filter { allowedCharacters.contains(it) }
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
label = { Text(stringResource(id = R.string.send_amount)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = recipientAddressString,
|
||||||
|
onValueChange = { recipientAddressString = it },
|
||||||
|
label = { Text(stringResource(id = R.string.send_to)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
TextField(value = memoString, onValueChange = {
|
||||||
|
if (Memo.isWithinMaxLength(it)) {
|
||||||
|
memoString = it
|
||||||
|
}
|
||||||
|
}, label = { Text(stringResource(id = R.string.send_memo)) })
|
||||||
|
|
||||||
|
Spacer(Modifier.fillMaxHeight(MINIMAL_WEIGHT))
|
||||||
|
|
||||||
|
if (validation.isNotEmpty()) {
|
||||||
|
/*
|
||||||
|
* Note: this is not localized in that it uses the enum constant name and joins the string
|
||||||
|
* without regard for RTL. This will get resolved once we do proper validation for
|
||||||
|
* the fields.
|
||||||
|
*/
|
||||||
|
Text(validation.joinToString(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
PrimaryButton(
|
||||||
|
onClick = {
|
||||||
|
val zecSendValidation = ZecSend.new(
|
||||||
|
recipientAddressString,
|
||||||
|
amountZecString,
|
||||||
|
memoString,
|
||||||
|
monetarySeparators
|
||||||
|
)
|
||||||
|
|
||||||
|
when (zecSendValidation) {
|
||||||
|
is ZecSendValidation.Valid -> onCreateAndSend(zecSendValidation.zecSend)
|
||||||
|
is ZecSendValidation.Invalid -> validation = zecSendValidation.validationErrors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = stringResource(id = R.string.send_create),
|
||||||
|
// Check for ABBREVIATION_INDEX goes away once proper address validation is in place.
|
||||||
|
// For now, it just prevents a crash on the confirmation screen.
|
||||||
|
enabled = amountZecString.isNotBlank() && recipientAddressString.length > ABBREVIATION_INDEX
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Confirmation(zecSend: ZecSend, onConfirmation: () -> Unit) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.send_amount_and_address_format,
|
||||||
|
zecSend.amount.toZecString(),
|
||||||
|
zecSend.destination.abbreviated()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO [#249]: Implement press-and-hold
|
||||||
|
Button(onClick = onConfirmation) {
|
||||||
|
Text(text = stringResource(id = R.string.send_confirm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="send_title">Send ZEC</string>
|
||||||
|
<string name="send_back_content_description">Back</string>
|
||||||
|
<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 and hold to send ZEC</string>
|
||||||
|
|
||||||
|
</resources>
|
Loading…
Reference in New Issue