[#135] Request ZEC scaffold

This commit is contained in:
Carter Jernigan 2022-02-17 08:08:06 -05:00 committed by GitHub
parent 52588e6bd3
commit a1199e706e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 689 additions and 21 deletions

View File

@ -26,11 +26,11 @@ If you wish to report a security issue, please follow our Responsible Disclosure
2.
3.
### Expected behaviour
### Expected behavior
<!-- Tell us what should happen -->
### Actual behaviour + errors
<!-- Tell us what happens instead including any noticable error output (any messages
### Actual behavior + errors
<!-- Tell us what happens instead including any noticeable error output (any messages
displayed on-screen when e.g. a crash occurred) -->
<!-- Note: please do not include sensitive information. blur, scratch or annotate any
information like addresses, usernames, amounts or anything other that you might consider sensitive and it's not relevant to the problem you are reporting. -->

View File

@ -152,6 +152,26 @@ class ScreenshotTest {
it.performClick()
}
addressDetailsScreenshots(composeTestRule)
// Back to profile
composeTestRule.onNode(hasContentDescription(getStringResource(R.string.wallet_address_back_content_description))).also {
it.assertExists()
it.performClick()
}
// Back to home screen
composeTestRule.onNode(hasContentDescription(getStringResource(R.string.settings_back_content_description))).also {
it.assertExists()
it.performClick()
}
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 {
it.assertExists()
it.performClick()
}
requestZecScreenshots(composeTestRule)
}
}
@ -322,3 +342,11 @@ private fun addressDetailsScreenshots(composeTestRule: ComposeTestRule) {
ScreenshotTest.takeScreenshot("Addresses 1")
}
private fun requestZecScreenshots(composeTestRule: ComposeTestRule) {
composeTestRule.onNode(hasText(getStringResource(R.string.request_title))).also {
it.assertExists()
}
ScreenshotTest.takeScreenshot("Request 1")
}

View File

@ -22,6 +22,7 @@ dependencies {
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.kotlin.test)
if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) {
androidTestUtil(libs.androidx.test.orchestrator) {

View File

@ -0,0 +1,12 @@
package cash.z.ecc.sdk.model
import kotlin.test.assertFailsWith
class ZatoshiTest {
@kotlin.test.Test
fun minValue() {
assertFailsWith<IllegalArgumentException> {
Zatoshi(-1)
}
}
}

View File

@ -0,0 +1,78 @@
package cash.z.ecc.sdk.model
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Test
import kotlin.test.assertNull
class ZecStringTest {
companion object {
private val EN_US_MONETARY_SEPARATORS = MonetarySeparators(',', '.')
}
@Test
fun empty_string() {
val actual = Zatoshi.fromZecString("", EN_US_MONETARY_SEPARATORS)
val expected = null
assertEquals(expected, actual)
}
@Test
fun decimal_monetary_separator() {
val actual = Zatoshi.fromZecString("1.13", EN_US_MONETARY_SEPARATORS)
val expected = Zatoshi(113000000L)
assertEquals(expected, actual)
}
@Test
fun comma_grouping_separator() {
val actual = Zatoshi.fromZecString("1,130", EN_US_MONETARY_SEPARATORS)
val expected = Zatoshi(113000000000L)
assertEquals(expected, actual)
}
@Test
fun decimal_monetary_and() {
val actual = Zatoshi.fromZecString("1,130", EN_US_MONETARY_SEPARATORS)
val expected = Zatoshi(113000000000L)
assertEquals(expected, actual)
}
@Test
fun toZecString() {
val expected = "1.13000000"
val actual = Zatoshi(113000000).toZecString()
assertEquals(expected, actual)
}
@Test
@Ignore("https://github.com/zcash/zcash-android-wallet-sdk/issues/412")
fun round_trip() {
val expected = Zatoshi(113000000L)
val actual = Zatoshi.fromZecString(expected.toZecString(), EN_US_MONETARY_SEPARATORS)
assertEquals(expected, actual)
}
@Test
@Ignore("https://github.com/zcash/secant-android-wallet/issues/223")
fun parse_bad_string() {
val actual = Zatoshi.fromZecString("asdf", EN_US_MONETARY_SEPARATORS)
assertNull(actual)
}
@Test
@Ignore("https://github.com/zcash/secant-android-wallet/issues/223")
fun parse_bad_number() {
val actual = Zatoshi.fromZecString("1.2,3,4", EN_US_MONETARY_SEPARATORS)
assertNull(actual)
}
}

View File

@ -0,0 +1,19 @@
package cash.z.ecc.sdk.fixture
import cash.z.ecc.sdk.model.WalletAddress
import cash.z.ecc.sdk.model.Zatoshi
import cash.z.ecc.sdk.model.ZecRequest
import cash.z.ecc.sdk.model.ZecRequestMessage
object ZecRequestFixture {
const val ADDRESS: String = WalletAddressFixture.UNIFIED_ADDRESS_STRING
@Suppress("MagicNumber")
val AMOUNT = Zatoshi(123)
val MESSAGE = ZecRequestMessage("Thanks for lunch")
suspend fun new(
address: String = ADDRESS,
amount: Zatoshi = AMOUNT,
message: ZecRequestMessage = MESSAGE
) = ZecRequest(WalletAddress.Unified.new(address), amount, message)
}

View File

@ -1,17 +1,13 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
// Eventually, this could move into the SDK and provide a stronger API for amounts
/**
*
*/
@JvmInline
value class Zatoshi(val amount: Long) {
init {
require(amount >= 0)
}
override fun toString() = amount.convertZatoshiToZecString(DECIMALS, DECIMALS)
companion object {
private const val DECIMALS = 8
}
companion object
}

View File

@ -0,0 +1,35 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.sdk.fixture.ZecRequestFixture
import kotlinx.coroutines.runBlocking
data class ZecRequest(val address: WalletAddress.Unified, val amount: Zatoshi, val message: ZecRequestMessage) {
// https://github.com/zcash/zcash-android-wallet-sdk/issues/397
// TODO [#397]: There's an issue in the SDK to implement the parser
@Suppress("FunctionOnlyReturningConstant")
fun toUri(): String = ""
companion object {
@Suppress("UNUSED_PARAMETER")
suspend fun fromUri(uriString: String) {
// TODO [#397]: Use URI parser
runBlocking { ZecRequestFixture.new() }
}
}
}
@JvmInline
value class ZecRequestMessage(val message: String) {
init {
require(message.length <= MAX_MESSAGE_LENGTH)
}
companion object {
// TODO [#219]: Define a maximum message length
// Also note that the length varies from what the user types in versus the encoded version
// that is actually sent.
const val MAX_MESSAGE_LENGTH = 320
}
}

View File

@ -0,0 +1,92 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.ParseException
import java.util.Locale
object ZecString {
fun allowedCharacters(monetarySeparators: MonetarySeparators) = buildSet<Char> {
add('0')
add('1')
add('2')
add('3')
add('4')
add('5')
add('6')
add('7')
add('8')
add('9')
add(monetarySeparators.decimal)
add(monetarySeparators.grouping)
}
}
data class MonetarySeparators(val grouping: Char, val decimal: Char) {
init {
require(grouping != decimal) { "Grouping and decimal separator cannot be the same character" }
}
companion object {
/**
* @return The current localized monetary separators. Do not cache this value, as it
* can change if the system Locale changes.
*/
fun current(): MonetarySeparators {
val decimalFormatSymbols = DecimalFormatSymbols.getInstance()
return MonetarySeparators(
decimalFormatSymbols.groupingSeparator,
decimalFormatSymbols.monetaryDecimalSeparator
)
}
}
}
private const val DECIMALS = 8
// TODO [#412]: https://github.com/zcash/zcash-android-wallet-sdk/issues/412
// The SDK needs to fix the API for currency conversion
fun Zatoshi.toZecString() = amount.convertZatoshiToZecString(DECIMALS, DECIMALS)
/*
* ZEC is our own currency, so there's not going to be an existing localization that matches it perfectly.
*
* To ensure consistent behavior regardless of user Locale, use US localization except that we swap out the
* separator characters based on the user's current Locale. This should avoid unexpected surprises
* while also localizing the separator format.
*/
/**
* @return [zecString] parsed into Zatoshi or null if parsing failed.
*/
fun Zatoshi.Companion.fromZecString(zecString: String, monetarySeparators: MonetarySeparators): Zatoshi? {
if (zecString.isBlank()) {
return null
}
val symbols = DecimalFormatSymbols.getInstance(Locale.US).apply {
this.groupingSeparator = monetarySeparators.grouping
this.decimalSeparator = monetarySeparators.decimal
}
val localizedPattern = "#${monetarySeparators.grouping}##0${monetarySeparators.decimal}0#"
val decimalFormat = DecimalFormat(localizedPattern, symbols).apply {
isParseBigDecimal = true
roundingMode = RoundingMode.HALF_EVEN // aka Bankers rounding
}
val bigDecimal = try {
decimalFormat.parse(zecString) as BigDecimal
} catch (e: NumberFormatException) {
null
} catch (e: ParseException) {
null
}
return Zatoshi(bigDecimal.convertZecToZatoshi())
}

View File

@ -137,6 +137,7 @@ dependencyResolutionManagement {
alias("google-material").to("com.google.android.material:material:$googleMaterialVersion")
alias("kotlin-stdlib").to("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
alias("kotlin-reflect").to("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
alias("kotlin-test").to("org.jetbrains.kotlin:kotlin-test:$kotlinVersion")
alias("kotlinx-coroutines-android").to("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
alias("kotlinx-coroutines-core").to("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
alias("zcash-sdk").to("cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")

View File

@ -32,6 +32,7 @@ android {
"src/main/res/ui/onboarding",
"src/main/res/ui/profile",
"src/main/res/ui/restore",
"src/main/res/ui/request",
"src/main/res/ui/seed",
"src/main/res/ui/settings",
"src/main/res/ui/wallet_address"
@ -65,6 +66,7 @@ dependencies {
androidTestImplementation(libs.androidx.compose.test.junit)
androidTestImplementation(libs.androidx.compose.test.manifest)
androidTestImplementation(libs.kotlin.reflect)
androidTestImplementation(libs.kotlin.test)
if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) {
androidTestUtil(libs.androidx.test.orchestrator) {

View File

@ -0,0 +1,219 @@
package cash.z.ecc.ui.screen.request.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.WalletAddressFixture
import cash.z.ecc.sdk.fixture.ZecRequestFixture
import cash.z.ecc.sdk.model.MonetarySeparators
import cash.z.ecc.sdk.model.Zatoshi
import cash.z.ecc.sdk.model.ZecRequest
import cash.z.ecc.sdk.model.ZecRequestMessage
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.runBlocking
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 RequestViewTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun create_button_disabled() {
@Suppress("UNUSED_VARIABLE")
val testSetup = TestSetup(composeTestRule)
composeTestRule.onNodeWithText(getStringResource(R.string.request_create)).also {
it.assertIsNotEnabled()
}
}
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun create_request_no_message() = runTest {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getOnCreateCount())
assertEquals(null, testSetup.getLastCreateZecRequest())
composeTestRule.onNodeWithText(getStringResource(R.string.request_amount)).also {
val separators = MonetarySeparators.current()
it.performTextInput("{${separators.decimal}}123")
}
composeTestRule.onNodeWithText(getStringResource(R.string.request_create)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnCreateCount())
testSetup.getLastCreateZecRequest().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.address)
assertEquals(Zatoshi(12300000), it.amount)
assertTrue(it.message.message.isEmpty())
}
}
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun create_request_with_message() = runTest {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getOnCreateCount())
assertEquals(null, testSetup.getLastCreateZecRequest())
composeTestRule.onNodeWithText(getStringResource(R.string.request_amount)).also {
val separators = MonetarySeparators.current()
it.performTextInput("{${separators.decimal}}123")
}
composeTestRule.onNodeWithText(getStringResource(R.string.request_message)).also {
it.performTextInput(ZecRequestFixture.MESSAGE.message)
}
composeTestRule.onNodeWithText(getStringResource(R.string.request_create)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnCreateCount())
testSetup.getLastCreateZecRequest().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.address)
assertEquals(Zatoshi(12300000), it.amount)
assertEquals(ZecRequestFixture.MESSAGE.message, it.message.message)
}
}
@Test
@MediumTest
@Ignore("https://github.com/zcash/secant-android-wallet/issues/218")
fun create_request_illegal_input() {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getOnCreateCount())
assertEquals(null, testSetup.getLastCreateZecRequest())
composeTestRule.onNodeWithText(getStringResource(R.string.request_amount)).also {
val separators = MonetarySeparators.current()
it.performTextInput("{${separators.decimal}}1{${separators.decimal}}2{${separators.decimal}}3{${separators.decimal}}4")
}
composeTestRule.onNodeWithText(getStringResource(R.string.request_create)).also {
it.performClick()
}
assertEquals(0, testSetup.getOnCreateCount())
}
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun max_message_length() = runTest {
val testSetup = TestSetup(composeTestRule)
composeTestRule.onNodeWithText(getStringResource(R.string.request_amount)).also {
val separators = MonetarySeparators.current()
it.performTextInput("{${separators.decimal}}123")
}
composeTestRule.onNodeWithText(getStringResource(R.string.request_message)).also {
val input = buildString {
repeat(ZecRequestMessage.MAX_MESSAGE_LENGTH + 1) { _ ->
append("$it")
}
}
it.performTextInput(input)
}
composeTestRule.onNodeWithText(getStringResource(R.string.request_create)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnCreateCount())
testSetup.getLastCreateZecRequest().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.address)
assertEquals(Zatoshi(12300000), it.amount)
assertTrue(it.message.message.isEmpty())
}
}
@Test
@MediumTest
fun back() {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.request_back_content_description)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnBackCount())
}
private class TestSetup(private val composeTestRule: ComposeContentTestRule) {
private val onBackCount = AtomicInteger(0)
private val onCreateCount = AtomicInteger(0)
@Volatile
private var onCreateZecRequest: ZecRequest? = null
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
}
fun getOnCreateCount(): Int {
composeTestRule.waitForIdle()
return onCreateCount.get()
}
fun getLastCreateZecRequest(): ZecRequest? {
composeTestRule.waitForIdle()
return onCreateZecRequest
}
init {
composeTestRule.setContent {
ZcashTheme {
Request(
myAddress = runBlocking { WalletAddressFixture.unified() },
goBack = {
onBackCount.incrementAndGet()
},
onCreateAndSend = {
onCreateCount.incrementAndGet()
onCreateZecRequest = it
}
)
}
}
}
}
}

View File

@ -54,8 +54,8 @@ class SeedViewTest {
private class TestSetup(private val composeTestRule: ComposeContentTestRule) {
private var onBackCount = AtomicInteger(0)
private var onCopyToClipboardCount = AtomicInteger(0)
private val onBackCount = AtomicInteger(0)
private val onCopyToClipboardCount = AtomicInteger(0)
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()

View File

@ -79,10 +79,10 @@ class SettingsViewTest {
private class TestSetup(private val composeTestRule: ComposeContentTestRule) {
private var onBackCount = AtomicInteger(0)
private var onBackupCount = AtomicInteger(0)
private var onRescanCount = AtomicInteger(0)
private var onWipeCount = AtomicInteger(0)
private val onBackCount = AtomicInteger(0)
private val onBackupCount = AtomicInteger(0)
private val onRescanCount = AtomicInteger(0)
private val onWipeCount = AtomicInteger(0)
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()

View File

@ -3,6 +3,7 @@ package cash.z.ecc.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.SystemClock
import androidx.activity.ComponentActivity
@ -26,6 +27,7 @@ import cash.z.ecc.android.sdk.type.WalletBirthday
import cash.z.ecc.android.sdk.type.ZcashNetwork
import cash.z.ecc.sdk.model.PersistableWallet
import cash.z.ecc.sdk.model.SeedPhrase
import cash.z.ecc.sdk.model.ZecRequest
import cash.z.ecc.sdk.type.fromResources
import cash.z.ecc.ui.screen.backup.view.BackupWallet
import cash.z.ecc.ui.screen.backup.viewmodel.BackupViewModel
@ -36,6 +38,7 @@ import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel
import cash.z.ecc.ui.screen.onboarding.view.Onboarding
import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel
import cash.z.ecc.ui.screen.profile.view.Profile
import cash.z.ecc.ui.screen.request.view.Request
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.RestoreViewModel
@ -215,7 +218,7 @@ class MainActivity : ComponentActivity() {
goScan = {},
goProfile = { navController.navigate(NAV_PROFILE) },
goSend = {},
goRequest = {}
goRequest = { navController.navigate(NAV_REQUEST) }
)
}
composable(NAV_PROFILE) {
@ -252,6 +255,9 @@ class MainActivity : ComponentActivity() {
}
)
}
composable(NAV_REQUEST) {
WrapRequest(goBack = { navController.popBackStack() })
}
}
}
@ -373,6 +379,28 @@ class MainActivity : ComponentActivity() {
}
}
@Composable
private fun WrapRequest(
goBack: () -> Unit
) {
val walletAddresses = walletViewModel.addresses.collectAsState().value
if (null == walletAddresses) {
// Display loading indicator
} else {
Request(
walletAddresses.unified,
goBack = goBack,
onCreateAndSend = {
val chooserIntent = Intent.createChooser(it.newShareIntent(applicationContext), null)
startActivity(chooserIntent)
goBack()
},
)
}
}
companion object {
@VisibleForTesting
internal val SPLASH_SCREEN_DELAY = 0.seconds
@ -391,6 +419,9 @@ class MainActivity : ComponentActivity() {
@VisibleForTesting
const val NAV_SEED = "seed"
@VisibleForTesting
const val NAV_REQUEST = "request"
}
}
@ -416,3 +447,9 @@ private suspend fun prefetchFontLegacy(context: Context, @FontRes fontRes: Int)
withContext(Dispatchers.IO) {
ResourcesCompat.getFont(context, fontRes)
}
private fun ZecRequest.newShareIntent(context: Context) = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, context.getString(R.string.request_template_format, toUri()))
type = "text/plain"
}

View File

@ -33,7 +33,8 @@ fun ButtonComposablePreview() {
fun PrimaryButton(
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Button(
onClick = onClick,
@ -42,6 +43,7 @@ fun PrimaryButton(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
),
enabled = enabled,
colors = buttonColors(backgroundColor = MaterialTheme.colors.primary)
) {
Text(style = MaterialTheme.typography.button, text = text, color = MaterialTheme.colors.onPrimary)

View File

@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.db.entity.Transaction
import cash.z.ecc.sdk.model.toZecString
import cash.z.ecc.sdk.model.total
import cash.z.ecc.ui.R
import cash.z.ecc.ui.fixture.WalletSnapshotFixture
@ -126,11 +127,11 @@ private fun HomeMainContent(
@Composable
private fun Status(walletSnapshot: WalletSnapshot) {
Column(Modifier.fillMaxWidth()) {
Header(text = walletSnapshot.totalBalance().toString())
Header(text = walletSnapshot.totalBalance().toZecString())
Body(
text = stringResource(
id = R.string.home_status_shielding_format,
walletSnapshot.saplingBalance.total.toString()
walletSnapshot.saplingBalance.total.toZecString()
)
)
}

View File

@ -0,0 +1,133 @@
package cash.z.ecc.ui.screen.request.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
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.unit.dp
import cash.z.ecc.sdk.fixture.WalletAddressFixture
import cash.z.ecc.sdk.model.MonetarySeparators
import cash.z.ecc.sdk.model.WalletAddress
import cash.z.ecc.sdk.model.Zatoshi
import cash.z.ecc.sdk.model.ZecRequest
import cash.z.ecc.sdk.model.ZecRequestMessage
import cash.z.ecc.sdk.model.ZecString
import cash.z.ecc.sdk.model.fromZecString
import cash.z.ecc.ui.R
import cash.z.ecc.ui.screen.common.GradientSurface
import cash.z.ecc.ui.screen.common.PrimaryButton
import cash.z.ecc.ui.theme.MINIMAL_WEIGHT
import cash.z.ecc.ui.theme.ZcashTheme
import kotlinx.coroutines.runBlocking
@Composable
fun PreviewRequest() {
ZcashTheme(darkTheme = true) {
GradientSurface {
Request(
myAddress = runBlocking { WalletAddressFixture.unified() },
goBack = {},
onCreateAndSend = {}
)
}
}
}
/**
* @param myAddress The address that ZEC should be sent to.
*/
@Composable
fun Request(
myAddress: WalletAddress.Unified,
goBack: () -> Unit,
onCreateAndSend: (ZecRequest) -> Unit,
) {
Scaffold(topBar = {
RequestTopAppBar(onBack = goBack)
}) {
RequestMainContent(
myAddress,
onCreateAndSend = onCreateAndSend
)
}
}
@Composable
private fun RequestTopAppBar(onBack: () -> Unit) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.request_title)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.request_back_content_description)
)
}
}
)
}
// TODO [#215]: Need to add some UI to explain to the user if a request is invalid
// TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button.
@Composable
private fun RequestMainContent(
myAddress: WalletAddress.Unified,
onCreateAndSend: (ZecRequest) -> Unit
) {
val monetarySeparators = MonetarySeparators.current()
val allowedCharacters = ZecString.allowedCharacters(monetarySeparators)
var amountZecString by rememberSaveable { mutableStateOf("") }
var message by rememberSaveable { mutableStateOf("") }
Column(Modifier.fillMaxHeight()) {
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.request_amount)) }
)
Spacer(Modifier.size(8.dp))
TextField(value = message, onValueChange = {
if (it.length <= ZecRequestMessage.MAX_MESSAGE_LENGTH) {
message = it
}
}, label = { Text(stringResource(id = R.string.request_message)) })
Spacer(Modifier.fillMaxHeight(MINIMAL_WEIGHT))
val zatoshi = Zatoshi.fromZecString(amountZecString, monetarySeparators)
PrimaryButton(
onClick = {
onCreateAndSend(ZecRequest(myAddress, zatoshi!!, ZecRequestMessage(message)))
},
text = stringResource(id = R.string.request_create),
enabled = null != zatoshi
)
}
}

View File

@ -0,0 +1,12 @@
<resources xmlns:tools="http://schemas.android.com/tools" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="request_title">Request ZEC</string>
<string name="request_back_content_description">Back</string>
<string name="request_from">Who would you like to request ZEC from?</string>
<string name="request_amount">How much?</string>
<string name="request_message">Message</string>
<string name="request_create">Create and Send</string>
<string name="request_template_format" formatted="true">Someone is requesting ZEC from you. Please click the link: <xliff:g id="link" example="zcash:asdf">%1$s</xliff:g></string>
</resources>