[#1144] Send.Form screen rework

* [#1144] Send.Form screen rework

- This follows the new Figma design. It also adds the TextFields values validation and proper UI reactions.
- Closes #340
- Closes #810
- Closes #1157
- Closes #1158
- Closes #1253
- Closes #1254
- Closes #826
- Follow-ups: #1047, #1257

* Changelog update
This commit is contained in:
Honza Rychnovský 2024-02-27 10:13:44 +01:00 committed by GitHub
parent 4bba077fbd
commit 7285137f2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 1114 additions and 357 deletions

View File

@ -14,6 +14,10 @@ directly impact users rather than highlighting other key architectural updates.*
unmetered connection and is plugged into the power, the background task will start to synchronize blocks randomly
between 3 and 4 a.m.
### Changed
- The Send screen form has changed its UI to align with the Figma design. All the form fields provide validations
and proper UI response.
## [0.2.0 (554)] - 2024-02-13
### Changed

View File

@ -179,6 +179,7 @@ KOTLIN_VERSION=1.9.21
KOTLINX_COROUTINES_VERSION=1.7.3
KOTLINX_DATETIME_VERSION=0.5.0
KOTLINX_IMMUTABLE_COLLECTIONS_VERSION=0.3.6
KOTLINX_SERIALIZABLE_JSON_VERSION=1.6.3
KOVER_VERSION=0.7.3
PLAY_APP_UPDATE_VERSION=2.1.0
PLAY_APP_UPDATE_KTX_VERSION=2.1.0

View File

@ -65,6 +65,7 @@ pluginManagement {
kotlin("android") version (kotlinVersion) apply (false)
kotlin("jvm") version (kotlinVersion)
kotlin("multiplatform") version (kotlinVersion)
kotlin("plugin.serialization") version (kotlinVersion)
}
}
@ -175,6 +176,7 @@ dependencyResolutionManagement {
val kotlinxDateTimeVersion = extra["KOTLINX_DATETIME_VERSION"].toString()
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
val kotlinxImmutableCollectionsVersion = extra["KOTLINX_IMMUTABLE_COLLECTIONS_VERSION"].toString()
val kotlinxSerializableJsonVersion = extra["KOTLINX_SERIALIZABLE_JSON_VERSION"].toString()
val playAppUpdateVersion = extra["PLAY_APP_UPDATE_VERSION"].toString()
val playAppUpdateKtxVersion = extra["PLAY_APP_UPDATE_KTX_VERSION"].toString()
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
@ -227,6 +229,7 @@ dependencyResolutionManagement {
library("kotlinx-coroutines-guava", "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlinxCoroutinesVersion")
library("kotlinx-datetime", "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
library("kotlinx-immutable", "org.jetbrains.kotlinx:kotlinx-collections-immutable:$kotlinxImmutableCollectionsVersion")
library("kotlinx-serializable-json", "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializableJsonVersion")
library("play-update", "com.google.android.play:app-update:$playAppUpdateVersion")
library("play-update-ktx", "com.google.android.play:app-update-ktx:$playAppUpdateKtxVersion")
library("zcash-sdk", "cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")

View File

@ -2,17 +2,16 @@ package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusEvent
@ -20,6 +19,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import kotlinx.coroutines.launch
@ -31,40 +32,53 @@ fun FormTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
error: String? = null,
enabled: Boolean = true,
textStyle: TextStyle = ZcashTheme.extendedTypography.textFieldValue,
label: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
placeholder:
@Composable()
(() -> Unit)? = null,
leadingIcon:
@Composable()
(() -> Unit)? = null,
trailingIcon:
@Composable()
(() -> Unit)? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors: TextFieldColors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
disabledContainerColor = ZcashTheme.colors.textDisabled,
errorContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
keyboardActions: KeyboardActions = KeyboardActions.Default,
shape: Shape = TextFieldDefaults.shape,
// To enable border around the TextField
withBorder: Boolean = true,
bringIntoViewRequester: BringIntoViewRequester = remember { BringIntoViewRequester() }
bringIntoViewRequester: BringIntoViewRequester? = null,
minHeight: Dp = ZcashTheme.dimens.textFieldDefaultHeight,
) {
val coroutineScope = rememberCoroutineScope()
val composedModifier =
val composedTextFieldModifier =
modifier
.defaultMinSize(minHeight = ZcashTheme.dimens.textFieldDefaultHeight)
.defaultMinSize(minHeight = minHeight)
.onFocusEvent { focusState ->
if (focusState.isFocused) {
coroutineScope.launch {
bringIntoViewRequester.bringIntoView()
bringIntoViewRequester?.run {
if (focusState.isFocused) {
coroutineScope.launch {
bringIntoView()
}
}
}
}
.bringIntoViewRequester(bringIntoViewRequester)
.then(
if (withBorder) {
modifier.border(width = 1.dp, color = MaterialTheme.colorScheme.primary)
modifier.border(width = 1.dp, color = ZcashTheme.colors.textFieldFrame)
} else {
Modifier
}
@ -73,14 +87,31 @@ fun FormTextField(
TextField(
value = value,
onValueChange = onValueChange,
label = label,
placeholder =
if (enabled) {
placeholder
} else {
null
},
textStyle = textStyle,
keyboardOptions = keyboardOptions,
colors = colors,
modifier = composedModifier,
modifier = composedTextFieldModifier,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
keyboardActions = keyboardActions,
shape = shape
shape = shape,
enabled = enabled
)
if (!error.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BodySmall(
text = error,
color = ZcashTheme.colors.textFieldError,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -36,7 +36,8 @@ data class Dimens(
val topAppBarZcashLogoHeight: Dp,
// TextField:
val textFieldDefaultHeight: Dp,
val textFieldPanelDefaultHeight: Dp,
val textFieldSeedPanelDefaultHeight: Dp,
val textFieldMemoPanelDefaultHeight: Dp,
// Any Layout:
val divider: Dp,
val layoutStroke: Dp,
@ -63,16 +64,17 @@ private val defaultDimens =
buttonShadowOffsetX = 20.dp,
buttonShadowOffsetY = 20.dp,
buttonShadowSpread = 10.dp,
buttonWidth = 230.dp,
buttonHeight = 50.dp,
buttonWidth = 244.dp,
buttonHeight = 70.dp,
chipShadowElevation = 4.dp,
chipStroke = 0.5.dp,
circularScreenProgressWidth = 48.dp,
circularSmallProgressWidth = 14.dp,
linearProgressHeight = 14.dp,
topAppBarZcashLogoHeight = 24.dp,
textFieldDefaultHeight = 64.dp,
textFieldPanelDefaultHeight = 215.dp,
textFieldDefaultHeight = 40.dp,
textFieldSeedPanelDefaultHeight = 215.dp,
textFieldMemoPanelDefaultHeight = 140.dp,
layoutStroke = 1.dp,
divider = 1.dp,
regularRippleEffectCorner = 28.dp,

View File

@ -20,7 +20,10 @@ data class ExtendedColors(
val linearProgressBarBackground: Color,
val chipIndex: Color,
val textCommon: Color,
val textDisabled: Color,
val textFieldHint: Color,
val textFieldError: Color,
val textFieldFrame: Color,
val textDescription: Color,
val textPending: Color,
val layoutStroke: Color,

View File

@ -23,9 +23,11 @@ internal object Dark {
val textPrimaryButton = Color(0xFF000000)
val textSecondaryButton = Color(0xFF000000)
val textTertiaryButton = Color.White
val textNavigationButton = Color.Black
val textCommon = Color(0xFFFFFFFF)
val textDisabled = Color(0xFFB7B7B7)
val textChipIndex = Color(0xFFFFB900)
val textFieldFrame = Color(0xFF231F20)
val textFieldError = Color(0xFFFF0000)
val textFieldHint = Color(0xFFB7B7B7)
val textDescription = Color(0xFF777777)
val textProgress = Color(0xFF8B8A8A)
@ -82,13 +84,15 @@ internal object Light {
val textHeaderOnBackground = Color(0xFF000000)
val textBodyOnBackground = Color(0xFF000000)
val textNavigationButton = Color(0xFFFFFFFF)
val textPrimaryButton = Color(0xFFFFFFFF)
val textSecondaryButton = Color(0xFF000000)
val textTertiaryButton = Color(0xFF000000)
val textCommon = Color(0xFF000000)
val textChipIndex = Color(0xFFEE8592)
val textDisabled = Color(0xFFB7B7B7)
val textFieldFrame = Color(0xFF231F20)
val textFieldError = Color(0xFFCD0002)
val textFieldHint = Color(0xFFB7B7B7)
val textChipIndex = Color(0xFFEE8592)
val textDescription = Color(0xFF777777)
val textProgress = Color(0xFF8B8A8A)
@ -175,6 +179,9 @@ internal val DarkExtendedColorPalette =
linearProgressBarBackground = Dark.linearProgressBarBackground,
chipIndex = Dark.textChipIndex,
textCommon = Dark.textCommon,
textDisabled = Dark.textDisabled,
textFieldFrame = Dark.textFieldFrame,
textFieldError = Dark.textFieldError,
textFieldHint = Dark.textFieldHint,
textDescription = Dark.textDescription,
textPending = Dark.textProgress,
@ -211,6 +218,9 @@ internal val LightExtendedColorPalette =
linearProgressBarBackground = Light.linearProgressBarBackground,
chipIndex = Light.textChipIndex,
textCommon = Light.textCommon,
textDisabled = Light.textDisabled,
textFieldFrame = Light.textFieldFrame,
textFieldError = Light.textFieldError,
textFieldHint = Light.textFieldHint,
textDescription = Light.textDescription,
textPending = Light.textProgress,
@ -249,7 +259,10 @@ internal val LocalExtendedColors =
linearProgressBarBackground = Color.Unspecified,
chipIndex = Color.Unspecified,
textCommon = Color.Unspecified,
textDisabled = Color.Unspecified,
textFieldHint = Color.Unspecified,
textFieldError = Color.Unspecified,
textFieldFrame = Color.Unspecified,
textDescription = Color.Unspecified,
textPending = Color.Unspecified,
layoutStroke = Color.Unspecified,

View File

@ -3,6 +3,7 @@ import com.android.build.api.variant.BuildConfigField
plugins {
id("com.android.library")
kotlin("android")
kotlin("plugin.serialization")
id("secant.android-build-conventions")
id("wtf.emulator.gradle")
id("secant.emulator-wtf-conventions")
@ -99,6 +100,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.guava)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.immutable)
implementation(libs.kotlinx.serializable.json)
implementation(libs.zcash.sdk)
implementation(libs.zcash.sdk.incubator)
implementation(libs.zcash.bip39)

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.common
import androidx.test.filters.FlakyTest
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.ui.common.extension.throttle
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking

View File

@ -1,6 +1,7 @@
package co.electriccoin.zcash.ui.common
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.ui.common.extension.first
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.contains

View File

@ -8,6 +8,9 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.compose.BrightenScreen
import co.electriccoin.zcash.ui.common.compose.LocalScreenBrightness
import co.electriccoin.zcash.ui.common.compose.ScreenBrightness
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow

View File

@ -9,6 +9,11 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.filters.MediumTest
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.compose.LocalScreenSecurity
import co.electriccoin.zcash.ui.common.compose.ScreenSecurity
import co.electriccoin.zcash.ui.common.compose.SecureScreen
import co.electriccoin.zcash.ui.common.compose.isRunningTest
import co.electriccoin.zcash.ui.common.compose.shouldSecureScreen
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update

View File

@ -8,6 +8,9 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.compose.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.compose.LocalScreenTimeout
import co.electriccoin.zcash.ui.common.compose.ScreenTimeout
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow

View File

@ -149,9 +149,7 @@ internal class MockSynchronizer : CloseableSynchronizer {
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
}
override suspend fun validateAddress(address: String): AddressType {
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
}
override suspend fun validateAddress(address: String): AddressType = AddressType.Unified
override suspend fun validateConsensusBranch(): ConsensusMatchType {
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")

View File

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

View File

@ -1,6 +1,5 @@
package co.electriccoin.zcash.ui.screen.balances.integration
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
@ -61,7 +60,7 @@ class BalancesViewIntegrationTest : UiTestPrerequisites() {
assertEquals(0.5f, testSetup.getWalletSnapshot().progress.decimal)
composeTestRule.onNodeWithTag(BalancesTag.STATUS).also {
it.assertIsDisplayed()
it.assertExists()
it.assertWidthIsAtLeast(1.dp)
}
}

View File

@ -6,8 +6,8 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.filters.MediumTest
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.LocalScreenSecurity
import co.electriccoin.zcash.ui.common.ScreenSecurity
import co.electriccoin.zcash.ui.common.compose.LocalScreenSecurity
import co.electriccoin.zcash.ui.common.compose.ScreenSecurity
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import kotlinx.coroutines.test.runTest
import org.junit.Rule

View File

@ -7,10 +7,10 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import cash.z.ecc.android.sdk.model.WalletAddresses
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.LocalScreenBrightness
import co.electriccoin.zcash.ui.common.LocalScreenTimeout
import co.electriccoin.zcash.ui.common.ScreenBrightness
import co.electriccoin.zcash.ui.common.ScreenTimeout
import co.electriccoin.zcash.ui.common.compose.LocalScreenBrightness
import co.electriccoin.zcash.ui.common.compose.LocalScreenTimeout
import co.electriccoin.zcash.ui.common.compose.ScreenBrightness
import co.electriccoin.zcash.ui.common.compose.ScreenTimeout
import co.electriccoin.zcash.ui.common.model.VersionInfo
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.VersionInfoFixture

View File

@ -7,8 +7,8 @@ import androidx.test.filters.MediumTest
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.sdk.model.ZcashNetwork
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.LocalScreenSecurity
import co.electriccoin.zcash.ui.common.ScreenSecurity
import co.electriccoin.zcash.ui.common.compose.LocalScreenSecurity
import co.electriccoin.zcash.ui.common.compose.ScreenSecurity
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.restore.state.RestoreState
import co.electriccoin.zcash.ui.screen.restore.state.WordList

View File

@ -6,8 +6,8 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.filters.MediumTest
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.LocalScreenSecurity
import co.electriccoin.zcash.ui.common.ScreenSecurity
import co.electriccoin.zcash.ui.common.compose.LocalScreenSecurity
import co.electriccoin.zcash.ui.common.compose.ScreenSecurity
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import kotlinx.coroutines.test.runTest
import org.junit.Rule

View File

@ -10,11 +10,12 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.ZecSendFixture
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.test.getAppContext
import co.electriccoin.zcash.ui.test.getStringResource
import co.electriccoin.zcash.ui.test.getStringResourceWithArgs
internal fun ComposeContentTestRule.clickBack() {
onNodeWithContentDescription(getStringResource(R.string.send_back_content_description)).also {
@ -35,44 +36,52 @@ internal fun ComposeContentTestRule.clickScanner() {
}
internal fun ComposeContentTestRule.setValidAmount() {
onNodeWithText(getStringResource(R.string.send_amount)).also {
val separators = MonetarySeparators.current()
onNodeWithText(
getStringResourceWithArgs(
R.string.send_amount_hint,
ZcashCurrency.fromResources(getAppContext()).name
)
).also {
it.performTextClearance()
it.performTextInput("123${separators.decimal}456")
it.performTextInput(ZecSendFixture.AMOUNT.value.toString())
}
}
internal fun ComposeContentTestRule.setAmount(amount: String) {
onNodeWithText(getStringResource(R.string.send_amount)).also {
onNodeWithText(
getStringResourceWithArgs(
R.string.send_amount_hint,
ZcashCurrency.fromResources(getAppContext()).name
)
).also {
it.performTextClearance()
it.performTextInput(amount)
}
}
internal fun ComposeContentTestRule.setValidAddress() {
onNodeWithText(getStringResource(R.string.send_to)).also {
onNodeWithText(getStringResource(R.string.send_address_hint)).also {
it.performTextClearance()
// Using sapling address here, as the unified is not available in the fixture. This will change.
it.performTextInput(WalletAddressFixture.SAPLING_ADDRESS_STRING)
it.performTextInput(ZecSendFixture.ADDRESS)
}
}
internal fun ComposeContentTestRule.setAddress(address: String) {
onNodeWithText(getStringResource(R.string.send_to)).also {
onNodeWithText(getStringResource(R.string.send_address_hint)).also {
it.performTextClearance()
it.performTextInput(address)
}
}
internal fun ComposeContentTestRule.setValidMemo() {
onNodeWithText(getStringResource(R.string.send_memo)).also {
onNodeWithText(getStringResource(R.string.send_memo_hint)).also {
it.performTextClearance()
it.performTextInput(MemoFixture.MEMO_STRING)
it.performTextInput(ZecSendFixture.MEMO.value)
}
}
internal fun ComposeContentTestRule.setMemo(memo: String) {
onNodeWithText(getStringResource(R.string.send_memo)).also {
onNodeWithText(getStringResource(R.string.send_memo_hint)).also {
it.performTextClearance()
it.performTextInput(memo)
}

View File

@ -4,26 +4,31 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import cash.z.ecc.android.sdk.fixture.WalletBalanceFixture
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.send.ext.Saver
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.AmountState
import co.electriccoin.zcash.ui.screen.send.model.MemoState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import co.electriccoin.zcash.ui.screen.send.model.SendStage
import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
class SendViewTestSetup(
private val composeTestRule: ComposeContentTestRule,
private val initialState: SendStage,
private val initialZecSend: ZecSend?,
private val initialSendArgumentWrapper: SendArgumentsWrapper?,
private val hasCameraFeature: Boolean
) {
private val onBackCount = AtomicInteger(0)
@ -71,6 +76,12 @@ class SendViewTestSetup(
@Composable
@Suppress("TestFunctionName")
fun DefaultContent() {
val context = LocalContext.current
// TODO [#1171]: Remove default MonetarySeparators locale
// TODO [#1171]: https://github.com/Electric-Coin-Company/zashi-android/issues/1171
val monetarySeparators = MonetarySeparators.current(Locale.US)
val (sendStage, setSendStage) =
rememberSaveable(stateSaver = SendStage.Saver) { mutableStateOf(initialState) }
@ -97,6 +108,8 @@ class SendViewTestSetup(
lastZecSend = zecSend
ZcashTheme {
// TODO [#1260]: Cover Send.Form screen UI with tests
// TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
Send(
walletSnapshot =
WalletSnapshotFixture.new(
@ -105,17 +118,12 @@ class SendViewTestSetup(
available = Zatoshi(Zatoshi.MAX_INCLUSIVE.div(100))
)
),
focusManager = LocalFocusManager.current,
sendStage = sendStage,
sendArgumentsWrapper = initialSendArgumentWrapper,
onSendStageChange = setSendStage,
zecSend = zecSend,
onZecSendChange = setZecSend,
focusManager = LocalFocusManager.current,
onBack = onBackAction,
goBalances = {
// TODO [#1194]: Cover Current balances UI widget with tests
// TODO [#1194]: https://github.com/Electric-Coin-Company/zashi-android/issues/1194
},
onSettings = { onSettingsCount.incrementAndGet() },
onCreateAndSend = {
onCreateCount.incrementAndGet()
@ -125,7 +133,20 @@ class SendViewTestSetup(
onQrScannerOpen = {
onScannerCount.incrementAndGet()
},
hasCameraFeature = hasCameraFeature
goBalances = {
// TODO [#1194]: Cover Current balances UI widget with tests
// TODO [#1194]: https://github.com/Electric-Coin-Company/zashi-android/issues/1194
},
hasCameraFeature = hasCameraFeature,
recipientAddressState = RecipientAddressState("", AddressType.Invalid()),
onRecipientAddressChange = {
// TODO [#1260]: Cover Send.Form screen UI with tests
// TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
},
amountState = AmountState.new(context, "", monetarySeparators),
setAmountState = {},
memoState = MemoState.new(""),
setMemoState = {},
)
}
}

View File

@ -1,17 +1,16 @@
package co.electriccoin.zcash.ui.screen.send.integration
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.filters.MediumTest
import cash.z.ecc.android.sdk.fixture.WalletBalanceFixture
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.fixture.ZecSendFixture
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.fixture.MockSynchronizer
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.send.WrapSend
@ -22,10 +21,10 @@ import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend
import co.electriccoin.zcash.ui.screen.send.setAddress
import co.electriccoin.zcash.ui.screen.send.setAmount
import co.electriccoin.zcash.ui.screen.send.setMemo
import co.electriccoin.zcash.ui.test.getStringResource
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import java.util.Locale
class SendViewIntegrationTest {
@get:Rule
@ -49,6 +48,10 @@ class SendViewIntegrationTest {
)
)
// TODO [#1171]: Remove default MonetarySeparators locale
// TODO [#1171]: https://github.com/Electric-Coin-Company/zashi-android/issues/1171
private val monetarySeparators = MonetarySeparators.current(Locale.US)
@Test
@MediumTest
fun send_screens_values_state_restoration() {
@ -69,7 +72,8 @@ class SendViewIntegrationTest {
goBack = {},
goBalances = {},
hasCameraFeature = true,
goSettings = {}
goSettings = {},
monetarySeparators = monetarySeparators
)
}
@ -92,24 +96,14 @@ class SendViewIntegrationTest {
composeTestRule.clickBack()
composeTestRule.assertOnForm()
// And check recreated form values too. Note also that we don't check the amount field value, as it's changed
// by validation mechanisms
// We use that the assertTextEquals searches in SemanticsProperties.EditableText too, although to be able to
// compare its editable value to an exact match we need to pass all its texts
composeTestRule.onNodeWithText(getStringResource(R.string.send_to)).also {
it.assertTextEquals(
getStringResource(R.string.send_to),
ZecSendFixture.ADDRESS,
includeEditableText = true
)
composeTestRule.onNodeWithText(ZecSendFixture.ADDRESS).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also {
it.assertTextEquals(
getStringResource(R.string.send_memo),
ZecSendFixture.MEMO.value,
includeEditableText = true
)
composeTestRule.onNodeWithText(ZecSendFixture.AMOUNT.value.toString()).also {
it.assertExists()
}
composeTestRule.onNodeWithText(ZecSendFixture.MEMO.value).also {
it.assertExists()
}
}
}

View File

@ -32,8 +32,7 @@ class SendViewAndroidTest : UiTestPrerequisites() {
composeTestRule,
sendStage,
zecSend,
null,
true
true,
).apply {
setDefaultContent()
}

View File

@ -34,7 +34,6 @@ import co.electriccoin.zcash.ui.screen.send.clickConfirmation
import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend
import co.electriccoin.zcash.ui.screen.send.clickScanner
import co.electriccoin.zcash.ui.screen.send.clickSettingsTopAppBarMenu
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.SendStage
import co.electriccoin.zcash.ui.screen.send.setAmount
import co.electriccoin.zcash.ui.screen.send.setMemo
@ -47,7 +46,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@ -59,13 +59,11 @@ class SendViewTest : UiTestPrerequisites() {
private fun newTestSetup(
sendStage: SendStage = SendStage.Form,
zecSend: ZecSend? = null,
sendArgumentsWrapper: SendArgumentsWrapper? = null,
hasCameraFeature: Boolean = true
) = SendViewTestSetup(
composeTestRule,
sendStage,
zecSend,
sendArgumentsWrapper,
hasCameraFeature
).apply {
setDefaultContent()
@ -85,6 +83,7 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@Ignore("Currently disabled. Will be implemented as part of #1260")
fun create_request_no_memo() =
runTest {
val testSetup = newTestSetup()
@ -119,6 +118,7 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@Ignore("Currently disabled. Will be implemented as part of #1260")
fun create_request_with_memo() =
runTest {
val testSetup = newTestSetup()
@ -155,6 +155,7 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@Ignore("Currently disabled. Will be implemented as part of #1260")
fun check_regex_functionality_valid_inputs() =
runTest {
val testSetup = newTestSetup()
@ -251,6 +252,7 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@Ignore("Currently disabled. Will be implemented as part of #1260")
fun max_memo_length() =
runTest {
val testSetup = newTestSetup()
@ -263,6 +265,8 @@ class SendViewTest : UiTestPrerequisites() {
while (Memo.isWithinMaxLength(toString())) {
append("a")
}
// To align with the length limit restriction
deleteCharAt(length - 1)
}
composeTestRule.setMemo(input)
@ -304,6 +308,7 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@Ignore("Currently disabled. Will be implemented as part of #1260")
fun back_on_confirmation() {
val testSetup = newTestSetup()
@ -424,17 +429,14 @@ class SendViewTest : UiTestPrerequisites() {
assertEquals(1, testSetup.getOnScannerCount())
}
// TODO [#1260]: Cover Send.Form screen UI with tests
// TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
@Test
@MediumTest
@Ignore("Currently disabled. Will be implemented as part of #1260")
fun input_arguments_to_form() {
newTestSetup(
sendStage = SendStage.Form,
sendArgumentsWrapper =
SendArgumentsWrapperFixture.new(
recipientAddress = SendArgumentsWrapperFixture.RECIPIENT_ADDRESS,
amount = SendArgumentsWrapperFixture.AMOUNT,
memo = SendArgumentsWrapperFixture.MEMO
),
zecSend = null
)
@ -442,23 +444,23 @@ class SendViewTest : UiTestPrerequisites() {
// We use that the assertTextEquals searches in SemanticsProperties.EditableText too, although to be able to
// compare its editable value to an exact match we need to pass all its texts
composeTestRule.onNodeWithText(getStringResource(R.string.send_to)).also {
composeTestRule.onNodeWithText(getStringResource(R.string.send_address_hint)).also {
it.assertTextEquals(
getStringResource(R.string.send_to),
SendArgumentsWrapperFixture.RECIPIENT_ADDRESS,
getStringResource(R.string.send_address_hint),
SendArgumentsWrapperFixture.RECIPIENT_ADDRESS.address,
includeEditableText = true
)
}
composeTestRule.onNodeWithText(getStringResource(R.string.send_amount)).also {
composeTestRule.onNodeWithText(getStringResource(R.string.send_amount_hint)).also {
it.assertTextEquals(
getStringResource(R.string.send_amount),
getStringResource(R.string.send_amount_hint),
SendArgumentsWrapperFixture.amountToFixtureZecString(SendArgumentsWrapperFixture.AMOUNT)!!,
includeEditableText = true
)
}
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo)).also {
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo_hint)).also {
it.assertTextEquals(
getStringResource(R.string.send_memo),
getStringResource(R.string.send_memo_hint),
SendArgumentsWrapperFixture.MEMO,
includeEditableText = true
)

View File

@ -6,6 +6,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.filters.MediumTest
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.test.UiTestPrerequisites
@ -134,6 +135,7 @@ class SettingsViewTest : UiTestPrerequisites() {
text = getStringResource(R.string.settings_about),
ignoreCase = true
).also {
it.performScrollTo()
it.performClick()
}

View File

@ -16,8 +16,7 @@
android:name=".MainActivity"
android:exported="false"
android:label="@string/app_name"
android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustResize" />
android:theme="@style/Theme.App.Starting" />
</application>

View File

@ -26,7 +26,7 @@ import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.ui.common.BindCompLocalProvider
import co.electriccoin.zcash.ui.common.compose.BindCompLocalProvider
import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.SecretState

View File

@ -28,11 +28,13 @@ import co.electriccoin.zcash.ui.screen.history.WrapHistory
import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.request.WrapRequest
import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator
import co.electriccoin.zcash.ui.screen.scan.model.ScanResult
import co.electriccoin.zcash.ui.screen.seedrecovery.WrapSeedRecovery
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.settings.WrapSettings
import co.electriccoin.zcash.ui.screen.support.WrapSupport
import co.electriccoin.zcash.ui.screen.update.WrapCheckForUpdate
import kotlinx.serialization.json.Json
@Composable
@Suppress("LongMethod")
@ -53,11 +55,15 @@ internal fun MainActivity.Navigation() {
goHistory = { navController.navigateJustOnce(HISTORY) },
goSettings = { navController.navigateJustOnce(SETTINGS) },
goScan = { navController.navigateJustOnce(SCAN) },
// At this point we only read scan result data
sendArgumentsWrapper =
SendArgumentsWrapper(
recipientAddress = backStackEntry.savedStateHandle[SEND_RECIPIENT_ADDRESS],
amount = backStackEntry.savedStateHandle[SEND_AMOUNT],
memo = backStackEntry.savedStateHandle[SEND_MEMO]
recipientAddress =
backStackEntry.savedStateHandle.get<String>(SEND_RECIPIENT_ADDRESS)?.let {
Json.decodeFromString<ScanResult>(it).toRecipient()
},
amount = backStackEntry.savedStateHandle.get<String>(SEND_AMOUNT),
memo = backStackEntry.savedStateHandle.get<String>(SEND_MEMO)
),
)
// Remove used Send screen parameters passed from the Scan screen if some exist
@ -120,10 +126,10 @@ internal fun MainActivity.Navigation() {
}
composable(SCAN) {
WrapScanValidator(
onScanValid = { result ->
// At this point we only pass recipient address
onScanValid = { scanResult ->
// At this point we only pass scan result data to recipient address
navController.previousBackStackEntry?.savedStateHandle?.apply {
set(SEND_RECIPIENT_ADDRESS, result)
set(SEND_RECIPIENT_ADDRESS, Json.encodeToString(ScanResult.serializer(), scanResult))
set(SEND_AMOUNT, null)
set(SEND_MEMO, null)
}

View File

@ -1,19 +0,0 @@
package co.electriccoin.zcash.ui.common
import androidx.compose.material3.DrawerState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
internal fun DrawerState.openDrawerMenu(scope: CoroutineScope) {
if (isOpen) {
return
}
scope.launch { open() }
}
internal fun DrawerState.closeDrawerMenu(scope: CoroutineScope) {
if (isClosed) {
return
}
scope.launch { close() }
}

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.common
package co.electriccoin.zcash.ui.common.compose
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
@ -40,19 +40,21 @@ private fun BalanceWidgetPreview() {
modifier = Modifier.fillMaxWidth()
) {
@Suppress("MagicNumber")
BalanceWidget(
walletSnapshot =
WalletSnapshotFixture.new(
saplingBalance =
WalletBalance(
Zatoshi(1234567891234567),
Zatoshi(123456789),
Zatoshi(123)
)
),
isReferenceToBalances = true,
onReferenceClick = {},
modifier = Modifier
(
BalanceWidget(
walletSnapshot =
WalletSnapshotFixture.new(
saplingBalance =
WalletBalance(
Zatoshi(1234567891234567),
Zatoshi(123456789),
Zatoshi(123)
)
),
isReferenceToBalances = true,
onReferenceClick = {},
modifier = Modifier
)
)
}
}

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.common
package co.electriccoin.zcash.ui.common.compose
import android.view.WindowManager
import androidx.activity.ComponentActivity

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.common
package co.electriccoin.zcash.ui.common.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.common
package co.electriccoin.zcash.ui.common.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.common
package co.electriccoin.zcash.ui.common.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect

View File

@ -0,0 +1,45 @@
package co.electriccoin.zcash.ui.common.extension
import cash.z.ecc.android.sdk.type.AddressType
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
fun AddressType.toSerializableName(): String =
when (this) {
AddressType.Transparent -> "transparent"
AddressType.Shielded -> "shielded"
AddressType.Unified -> "unified"
// Improve this with serializing reason
is AddressType.Invalid -> "invalid"
}
fun fromSerializableName(typeName: String): AddressType =
when (typeName) {
"transparent" -> AddressType.Transparent
"shielded" -> AddressType.Shielded
"unified" -> AddressType.Unified
// Improve this with deserializing reason
"invalid" -> AddressType.Invalid()
else -> error("Unsupported AddressType: $typeName")
}
object AddressTypeAsStringSerializer : KSerializer<AddressType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("AddressType", PrimitiveKind.STRING)
override fun serialize(
encoder: Encoder,
value: AddressType
) {
val string = value.toSerializableName()
encoder.encodeString(string)
}
override fun deserialize(decoder: Decoder): AddressType {
val string = decoder.decodeString()
return fromSerializableName(string)
}
}

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.common
package co.electriccoin.zcash.ui.common.extension
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers

View File

@ -1,5 +1,5 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.common
package co.electriccoin.zcash.ui.common.extension
fun <T> List<T>.first(count: Int) = subList(0, minOf(size, count))

View File

@ -1,6 +1,6 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.common
package co.electriccoin.zcash.ui.common.extension
import androidx.compose.ui.text.intl.Locale

View File

@ -26,9 +26,9 @@ import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.zcash.global.getInstance
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.common.extension.throttle
import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.throttle
import co.electriccoin.zcash.ui.preference.EncryptedPreferenceKeys
import co.electriccoin.zcash.ui.preference.EncryptedPreferenceSingleton
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys

View File

@ -20,8 +20,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.Synchronizer
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.BalanceWidget
import co.electriccoin.zcash.ui.common.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.compose.BalanceWidget
import co.electriccoin.zcash.ui.common.compose.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT

View File

@ -9,10 +9,10 @@ import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.toFiatCurrencyState
import cash.z.ecc.android.sdk.model.toZecString
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.extension.toKotlinLocale
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.model.totalBalance
import co.electriccoin.zcash.ui.common.toKotlinLocale
data class WalletDisplayValues(
val progress: PercentDecimal,

View File

@ -51,8 +51,8 @@ import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.sdk.extension.toPercentageWithDecimal
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.BalanceWidget
import co.electriccoin.zcash.ui.common.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.compose.BalanceWidget
import co.electriccoin.zcash.ui.common.compose.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.changePendingBalance
import co.electriccoin.zcash.ui.common.model.spendableBalance

View File

@ -28,8 +28,8 @@ import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.SecureScreen
import co.electriccoin.zcash.ui.common.shouldSecureScreen
import co.electriccoin.zcash.ui.common.compose.SecureScreen
import co.electriccoin.zcash.ui.common.compose.shouldSecureScreen
import co.electriccoin.zcash.ui.common.test.CommonTag.WALLET_BIRTHDAY
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.BodySmall

View File

@ -42,8 +42,8 @@ import cash.z.ecc.android.sdk.fixture.WalletAddressesFixture
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.WalletAddresses
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.BrightenScreen
import co.electriccoin.zcash.ui.common.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.compose.BrightenScreen
import co.electriccoin.zcash.ui.common.compose.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.model.VersionInfo
import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator

View File

@ -105,7 +105,6 @@ private fun RequestTopAppBar(onBack: () -> Unit) {
// 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.
// TODO [#288]: TextField component can't do long-press backspace.
@Composable
private fun RequestMainContent(
myAddress: WalletAddress.Unified,

View File

@ -1,7 +1,7 @@
package co.electriccoin.zcash.ui.screen.restore.model
import cash.z.ecc.android.sdk.model.SeedPhrase
import co.electriccoin.zcash.ui.common.first
import co.electriccoin.zcash.ui.common.extension.first
import java.util.Locale
internal sealed class ParseResult {

View File

@ -2,7 +2,7 @@ package co.electriccoin.zcash.ui.screen.restore.state
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.sdk.model.SeedPhraseValidation
import co.electriccoin.zcash.ui.common.first
import co.electriccoin.zcash.ui.common.extension.first
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow

View File

@ -66,8 +66,8 @@ import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.model.SeedPhraseValidation
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.SecureScreen
import co.electriccoin.zcash.ui.common.shouldSecureScreen
import co.electriccoin.zcash.ui.common.compose.SecureScreen
import co.electriccoin.zcash.ui.common.compose.shouldSecureScreen
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.ChipOnSurface
@ -488,7 +488,7 @@ private fun SeedGridWithText(
)
)
.fillMaxWidth()
.defaultMinSize(minHeight = ZcashTheme.dimens.textFieldPanelDefaultHeight)
.defaultMinSize(minHeight = ZcashTheme.dimens.textFieldSeedPanelDefaultHeight)
.then(modifier)
.testTag(RestoreTag.CHIP_LAYOUT)
) {

View File

@ -11,13 +11,14 @@ import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.scan.model.ScanResult
import co.electriccoin.zcash.ui.screen.scan.util.SettingsUtil
import co.electriccoin.zcash.ui.screen.scan.view.Scan
import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapScanValidator(
onScanValid: (address: String) -> Unit,
onScanValid: (address: ScanResult) -> Unit,
goBack: () -> Unit
) {
WrapScan(
@ -30,7 +31,7 @@ internal fun MainActivity.WrapScanValidator(
@Composable
fun WrapScan(
activity: ComponentActivity,
onScanValid: (address: String) -> Unit,
onScanValid: (address: ScanResult) -> Unit,
goBack: () -> Unit
) {
val walletViewModel by activity.viewModels<WalletViewModel>()
@ -51,9 +52,10 @@ fun WrapScan(
onBack = goBack,
onScanned = { result ->
scope.launch {
val isAddressValid = !synchronizer.validateAddress(result).isNotValid
val addressType = synchronizer.validateAddress(result)
val isAddressValid = !addressType.isNotValid
if (isAddressValid) {
onScanValid(result)
onScanValid(ScanResult(result, addressType))
} else {
snackbarHostState.showSnackbar(
message = activity.getString(R.string.scan_validation_invalid_address)

View File

@ -0,0 +1,22 @@
package co.electriccoin.zcash.ui.screen.scan.model
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.common.extension.AddressTypeAsStringSerializer
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import kotlinx.serialization.Serializable
@Serializable
data class ScanResult(
val address: String,
@Serializable(with = AddressTypeAsStringSerializer::class)
val type: AddressType
) {
init {
// Basic validation to support the class properties type-safeness
require(address.isNotEmpty()) {
"Address parameter $address can not be empty"
}
}
fun toRecipient() = RecipientAddressState(address, type)
}

View File

@ -94,7 +94,6 @@ private fun PreviewScan() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Scan(
snackbarHostState: SnackbarHostState,
@ -182,7 +181,7 @@ private fun ScanTopAppBar(onBack: () -> Unit) {
}
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("MagicNumber", "LongMethod", "LongParameterList")
@Suppress("LongMethod", "LongParameterList")
@Composable
private fun ScanMainContent(
onScanned: (String) -> Unit,
@ -232,6 +231,8 @@ private fun ScanMainContent(
val framePossibleSize = remember { mutableStateOf(IntSize.Zero) }
val configuration = LocalConfiguration.current
@Suppress("MagicNumber")
val frameActualSize =
if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
(framePossibleSize.value.height * 0.85).roundToInt()

View File

@ -28,8 +28,8 @@ import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.SecureScreen
import co.electriccoin.zcash.ui.common.shouldSecureScreen
import co.electriccoin.zcash.ui.common.compose.SecureScreen
import co.electriccoin.zcash.ui.common.compose.shouldSecureScreen
import co.electriccoin.zcash.ui.common.test.CommonTag.WALLET_BIRTHDAY
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.BodySmall

View File

@ -12,11 +12,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.sdk.extension.send
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
@ -25,10 +28,14 @@ import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex
import co.electriccoin.zcash.ui.screen.send.ext.Saver
import co.electriccoin.zcash.ui.screen.send.model.AmountState
import co.electriccoin.zcash.ui.screen.send.model.MemoState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.SendStage
import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.launch
import java.util.Locale
@Composable
@Suppress("LongParameterList")
@ -59,6 +66,10 @@ internal fun WrapSend(
focusManager.clearFocus(true)
}
// TODO [#1171]: Remove default MonetarySeparators locale
// TODO [#1171]: https://github.com/Electric-Coin-Company/zashi-android/issues/1171
val monetarySeparators = MonetarySeparators.current(Locale.US)
WrapSend(
sendArgumentsWrapper,
synchronizer,
@ -69,11 +80,12 @@ internal fun WrapSend(
goBack,
goBalances,
goSettings,
hasCameraFeature
hasCameraFeature,
monetarySeparators
)
}
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
@VisibleForTesting
@Composable
internal fun WrapSend(
@ -86,10 +98,13 @@ internal fun WrapSend(
goBack: () -> Unit,
goBalances: () -> Unit,
goSettings: () -> Unit,
hasCameraFeature: Boolean
hasCameraFeature: Boolean,
monetarySeparators: MonetarySeparators
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
// 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
@ -100,6 +115,50 @@ internal fun WrapSend(
val (zecSend, setZecSend) = rememberSaveable(stateSaver = ZecSend.Saver) { mutableStateOf(null) }
// Address computation:
val (recipientAddressState, setRecipientAddressState) =
rememberSaveable(stateSaver = RecipientAddressState.Saver) {
mutableStateOf(RecipientAddressState(zecSend?.destination?.address ?: "", null))
}
if (sendArgumentsWrapper?.recipientAddress != null) {
setRecipientAddressState(
RecipientAddressState.new(
sendArgumentsWrapper.recipientAddress.address,
sendArgumentsWrapper.recipientAddress.type
)
)
}
// Amount computation:
val (amountState, setAmountState) =
rememberSaveable(stateSaver = AmountState.Saver) {
mutableStateOf(
AmountState.new(
context = context,
value = zecSend?.amount?.toZecString() ?: "",
monetarySeparators = monetarySeparators
)
)
}
if (sendArgumentsWrapper?.amount != null) {
setAmountState(
AmountState.new(
context = context,
value = sendArgumentsWrapper.amount,
monetarySeparators = monetarySeparators
)
)
}
// Memo computation:
val (memoState, setMemoState) =
rememberSaveable(stateSaver = MemoState.Saver) {
mutableStateOf(MemoState.new(zecSend?.memo?.value ?: ""))
}
if (sendArgumentsWrapper?.memo != null) {
setMemoState(MemoState.new(sendArgumentsWrapper.memo))
}
val onBackAction = {
when (sendStage) {
SendStage.Form -> goBack()
@ -107,7 +166,12 @@ internal fun WrapSend(
SendStage.Sending -> { /* no action - wait until the sending is done */ }
is SendStage.SendFailure -> setSendStage(SendStage.Form)
SendStage.SendSuccessful -> {
// Reset Send.Form values
setZecSend(null)
setRecipientAddressState(RecipientAddressState.new(""))
setAmountState(AmountState.new(context, "", monetarySeparators))
setMemoState(MemoState.new(""))
setSendStage(SendStage.Form)
goBack()
}
@ -126,7 +190,6 @@ internal fun WrapSend(
} else {
Send(
walletSnapshot = walletSnapshot,
sendArgumentsWrapper = sendArgumentsWrapper,
sendStage = sendStage,
onSendStageChange = setSendStage,
zecSend = zecSend,
@ -134,6 +197,19 @@ internal fun WrapSend(
focusManager = focusManager,
onBack = onBackAction,
onSettings = goSettings,
recipientAddressState = recipientAddressState,
onRecipientAddressChange = {
scope.launch {
setRecipientAddressState(
RecipientAddressState.new(
address = it,
// TODO [#342]: Verify Addresses without Synchronizer
// TODO [#342]: https://github.com/zcash/zcash-android-wallet-sdk/issues/342
type = synchronizer.validateAddress(it)
)
)
}
},
onCreateAndSend = {
scope.launch {
Twig.debug { "Sending transaction" }
@ -148,6 +224,10 @@ internal fun WrapSend(
}
}
},
memoState = memoState,
setMemoState = setMemoState,
amountState = amountState,
setAmountState = setAmountState,
onQrScannerOpen = goToQrScanner,
goBalances = goBalances,
hasCameraFeature = hasCameraFeature

View File

@ -0,0 +1,93 @@
package co.electriccoin.zcash.ui.screen.send.model
import android.content.Context
import androidx.compose.runtime.saveable.mapSaver
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZecStringExt
import cash.z.ecc.android.sdk.model.fromZecString
import co.electriccoin.zcash.spackle.Twig
sealed class AmountState(
open val value: String,
) {
data class Valid(
override val value: String,
val zatoshi: Zatoshi
) : AmountState(value)
data class Invalid(
override val value: String,
) : AmountState(value)
companion object {
fun new(
context: Context,
value: String,
monetarySeparators: MonetarySeparators
): AmountState {
// Validate raw input string
val validated =
runCatching {
ZecStringExt.filterContinuous(context, monetarySeparators, value)
}.onFailure {
Twig.error(it) { "Failed while filtering raw amount characters" }
}.getOrDefault(false)
if (!validated) {
return Invalid(value)
}
// Convert the input to Zatoshi type-safe amount representation
val zatoshi = (Zatoshi.fromZecString(context, value, monetarySeparators))
// Note that the 0 funds sending is supported for sending a memo-only transaction
return if (zatoshi == null) {
Invalid(value)
} else {
Valid(value, zatoshi)
}
}
private const val TYPE_VALID = "valid" // $NON-NLS
private const val TYPE_INVALID = "invalid" // $NON-NLS
private const val KEY_TYPE = "type" // $NON-NLS
private const val KEY_VALUE = "value" // $NON-NLS
private const val KEY_ZATOSHI = "zatoshi" // $NON-NLS
internal val Saver
get() =
run {
mapSaver<AmountState>(
save = { it.toSaverMap() },
restore = {
if (it.isEmpty()) {
null
} else {
val amountString = (it[KEY_VALUE] as String)
val type = (it[KEY_TYPE] as String)
when (type) {
TYPE_VALID -> Valid(amountString, Zatoshi(it[KEY_ZATOSHI] as Long))
TYPE_INVALID -> Invalid(amountString)
else -> null
}
}
}
)
}
private fun AmountState.toSaverMap(): HashMap<String, Any> {
val saverMap = HashMap<String, Any>()
when (this) {
is Valid -> {
saverMap[KEY_TYPE] = TYPE_VALID
saverMap[KEY_ZATOSHI] = this.zatoshi.value
}
is Invalid -> saverMap[KEY_TYPE] = TYPE_INVALID
}
saverMap[KEY_VALUE] = this.value
return saverMap
}
}
}

View File

@ -0,0 +1,70 @@
package co.electriccoin.zcash.ui.screen.send.model
import androidx.compose.runtime.saveable.mapSaver
import cash.z.ecc.android.sdk.model.Memo
sealed class MemoState(
open val text: String,
open val byteSize: Int
) {
data class Correct(
override val text: String,
override val byteSize: Int
) : MemoState(text, byteSize)
data class TooLong(
override val text: String,
override val byteSize: Int
) : MemoState(text, byteSize)
companion object {
fun new(memo: String): MemoState {
val bytesCount = Memo.countLength(memo)
return if (bytesCount > Memo.MAX_MEMO_LENGTH_BYTES) {
TooLong(memo, bytesCount)
} else {
Correct(memo, bytesCount)
}
}
private const val TYPE_CORRECT = "correct" // $NON-NLS
private const val TYPE_TOO_LONG = "too_long" // $NON-NLS
private const val KEY_TYPE = "type" // $NON-NLS
private const val KEY_TEXT = "text" // $NON-NLS
private const val KEY_LENGTH = "length" // $NON-NLS
internal val Saver
get() =
run {
mapSaver<MemoState>(
save = { it.toSaverMap() },
restore = {
if (it.isEmpty()) {
null
} else {
val text = (it[KEY_TEXT] as String)
val length = (it[KEY_LENGTH] as Int)
val type = (it[KEY_TYPE] as String)
when (type) {
TYPE_CORRECT -> Correct(text, length)
TYPE_TOO_LONG -> TooLong(text, length)
else -> null
}
}
}
)
}
private fun MemoState.toSaverMap(): HashMap<String, Any> {
val saverMap = HashMap<String, Any>()
when (this) {
is Correct -> saverMap[KEY_TYPE] = TYPE_CORRECT
is TooLong -> saverMap[KEY_TYPE] = TYPE_TOO_LONG
}
saverMap[KEY_TEXT] = this.text
saverMap[KEY_LENGTH] = this.byteSize
return saverMap
}
}
}

View File

@ -0,0 +1,76 @@
package co.electriccoin.zcash.ui.screen.send.model
import androidx.compose.runtime.saveable.mapSaver
import cash.z.ecc.android.sdk.type.AddressType
data class RecipientAddressState(
val address: String,
val type: AddressType?
) {
companion object {
private const val KEY_ADDRESS = "address" // $NON-NLS
private const val KEY_TYPE = "type" // $NON-NLS
private const val KEY_INVALID_REASON = "invalid_reason" // $NON-NLS
private const val TYPE_INVALID = "invalid" // $NON-NLS
private const val TYPE_SHIELDED = "shielded" // $NON-NLS
private const val TYPE_TRANSPARENT = "transparent" // $NON-NLS
private const val TYPE_UNIFIED = "unified" // $NON-NLS
fun new(
address: String,
type: AddressType? = null
): RecipientAddressState = RecipientAddressState(address, type)
internal val Saver
get() =
run {
mapSaver(
save = { it.toSaverMap() },
restore = {
if (it.isEmpty()) {
null
} else {
val address = (it[KEY_ADDRESS] as String)
val type = (it[KEY_TYPE] as String?)
RecipientAddressState(
address,
when (type) {
TYPE_INVALID ->
AddressType.Invalid(
(it[KEY_INVALID_REASON] as String)
)
TYPE_SHIELDED -> AddressType.Shielded
TYPE_UNIFIED -> AddressType.Unified
TYPE_TRANSPARENT -> AddressType.Transparent
else -> null
}
)
}
}
)
}
private fun RecipientAddressState.toSaverMap(): HashMap<String, Any> {
val saverMap = HashMap<String, Any>()
saverMap[KEY_ADDRESS] = this.address
if (this.type != null) {
saverMap[KEY_TYPE] =
when (this.type) {
is AddressType.Invalid -> {
saverMap[KEY_INVALID_REASON] = this.type.reason
TYPE_INVALID
}
AddressType.Unified -> TYPE_UNIFIED
AddressType.Transparent -> TYPE_TRANSPARENT
AddressType.Shielded -> TYPE_SHIELDED
else -> error("Unsupported type: ${this.type}")
}
}
return saverMap
}
}
}

View File

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

View File

@ -2,30 +2,30 @@
package co.electriccoin.zcash.ui.screen.send.view
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
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.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
@ -35,6 +35,7 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
@ -46,33 +47,33 @@ import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.ZecSendExt
import cash.z.ecc.android.sdk.model.ZecString
import cash.z.ecc.android.sdk.model.ZecStringExt
import cash.z.ecc.android.sdk.model.fromZecString
import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.BalanceWidget
import co.electriccoin.zcash.ui.common.compose.BalanceWidget
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.BodySmall
import co.electriccoin.zcash.ui.design.component.FormTextField
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.Header
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.ZcashTheme.dimens
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.send.SendTag
import co.electriccoin.zcash.ui.screen.send.ext.ABBREVIATION_INDEX
import co.electriccoin.zcash.ui.screen.send.ext.abbreviated
import co.electriccoin.zcash.ui.screen.send.ext.valueOrEmptyChar
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.AmountState
import co.electriccoin.zcash.ui.screen.send.model.MemoState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import co.electriccoin.zcash.ui.screen.send.model.SendStage
import kotlinx.coroutines.runBlocking
import java.util.Locale
@ -84,18 +85,23 @@ private fun PreviewSendForm() {
GradientSurface {
Send(
walletSnapshot = WalletSnapshotFixture.new(),
focusManager = LocalFocusManager.current,
sendArgumentsWrapper = null,
sendStage = SendStage.Form,
onSendStageChange = {},
zecSend = null,
onZecSendChange = {},
onCreateAndSend = {},
onQrScannerOpen = {},
focusManager = LocalFocusManager.current,
onBack = {},
onSettings = {},
onCreateAndSend = {},
onQrScannerOpen = {},
goBalances = {},
hasCameraFeature = true
hasCameraFeature = true,
recipientAddressState = RecipientAddressState("invalid_address", AddressType.Invalid()),
onRecipientAddressChange = {},
setAmountState = {},
amountState = AmountState.Valid(ZatoshiFixture.ZATOSHI_LONG.toString(), ZatoshiFixture.new()),
setMemoState = {},
memoState = MemoState.new("Test message")
)
}
}
@ -160,7 +166,6 @@ private fun PreviewSendConfirmation() {
@Composable
fun Send(
walletSnapshot: WalletSnapshot,
sendArgumentsWrapper: SendArgumentsWrapper?,
sendStage: SendStage,
onSendStageChange: (SendStage) -> Unit,
zecSend: ZecSend?,
@ -171,7 +176,13 @@ fun Send(
onCreateAndSend: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit,
goBalances: () -> Unit,
hasCameraFeature: Boolean
hasCameraFeature: Boolean,
recipientAddressState: RecipientAddressState,
onRecipientAddressChange: (String) -> Unit,
setAmountState: (AmountState) -> Unit,
amountState: AmountState,
setMemoState: (MemoState) -> Unit,
memoState: MemoState,
) {
Scaffold(topBar = {
SendTopAppBar(
@ -182,13 +193,18 @@ fun Send(
}) { paddingValues ->
SendMainContent(
walletSnapshot = walletSnapshot,
sendArgumentsWrapper = sendArgumentsWrapper,
onBack = onBack,
focusManager = focusManager,
sendStage = sendStage,
onSendStageChange = onSendStageChange,
zecSend = zecSend,
onZecSendChange = onZecSendChange,
recipientAddressState = recipientAddressState,
onRecipientAddressChange = onRecipientAddressChange,
amountState = amountState,
setAmountState = setAmountState,
memoState = memoState,
setMemoState = setMemoState,
onSendSubmit = onCreateAndSend,
onQrScannerOpen = onQrScannerOpen,
goBalances = goBalances,
@ -196,10 +212,10 @@ fun Send(
modifier =
Modifier
.padding(
top = paddingValues.calculateTopPadding() + dimens.spacingDefault,
bottom = paddingValues.calculateBottomPadding() + dimens.spacingHuge,
start = dimens.screenHorizontalSpacingRegular,
end = dimens.screenHorizontalSpacingRegular
top = paddingValues.calculateTopPadding() + ZcashTheme.dimens.spacingDefault,
bottom = paddingValues.calculateBottomPadding() + ZcashTheme.dimens.spacingHuge,
start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
end = ZcashTheme.dimens.screenHorizontalSpacingRegular
)
)
}
@ -240,24 +256,33 @@ private fun SendTopAppBar(
private fun SendMainContent(
walletSnapshot: WalletSnapshot,
focusManager: FocusManager,
sendArgumentsWrapper: SendArgumentsWrapper?,
onBack: () -> Unit,
goBalances: () -> Unit,
zecSend: ZecSend?,
onZecSendChange: (ZecSend) -> Unit,
onBack: () -> Unit,
sendStage: SendStage,
onSendStageChange: (SendStage) -> Unit,
onSendSubmit: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit,
goBalances: () -> Unit,
recipientAddressState: RecipientAddressState,
onRecipientAddressChange: (String) -> Unit,
hasCameraFeature: Boolean,
modifier: Modifier = Modifier
amountState: AmountState,
setAmountState: (AmountState) -> Unit,
memoState: MemoState,
setMemoState: (MemoState) -> Unit,
modifier: Modifier = Modifier,
) {
when {
(sendStage == SendStage.Form || null == zecSend) -> {
SendForm(
walletSnapshot = walletSnapshot,
sendArgumentsWrapper = sendArgumentsWrapper,
previousZecSend = zecSend,
recipientAddressState = recipientAddressState,
onRecipientAddressChange = onRecipientAddressChange,
amountState = amountState,
setAmountState = setAmountState,
memoState = memoState,
setMemoState = setMemoState,
onCreateZecSend = {
onSendStageChange(SendStage.Confirmation)
onZecSendChange(it)
@ -304,63 +329,46 @@ private fun SendMainContent(
}
// TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button.
// TODO [#288]: TextField component can't do long-press backspace.
// TODO [#294]: DetektAll failed LongMethod
// TODO [#217]: https://github.com/Electric-Coin-Company/zashi-android/issues/217
// TODO [#1257]: Send.Form TextFields not persisted on a configuration change when the underlying ViewPager is on the
// Balances page
// TODO [#1257]: https://github.com/Electric-Coin-Company/zashi-android/issues/1257
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod")
@Suppress("LongMethod", "LongParameterList")
@Composable
private fun SendForm(
walletSnapshot: WalletSnapshot,
focusManager: FocusManager,
sendArgumentsWrapper: SendArgumentsWrapper?,
previousZecSend: ZecSend?,
recipientAddressState: RecipientAddressState,
onRecipientAddressChange: (String) -> Unit,
amountState: AmountState,
setAmountState: (AmountState) -> Unit,
memoState: MemoState,
setMemoState: (MemoState) -> Unit,
onCreateZecSend: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit,
goBalances: () -> Unit,
hasCameraFeature: Boolean,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
// TODO [#1171]: Remove default MonetarySeparators locale
// TODO [#1171]: https://github.com/Electric-Coin-Company/zashi-android/issues/1171
val monetarySeparators = MonetarySeparators.current(Locale.US)
val allowedCharacters = ZecString.allowedCharacters(monetarySeparators)
// TODO [#809]: Fix ZEC balance on Send screen
// TODO [#809]: https://github.com/Electric-Coin-Company/zashi-android/issues/809
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<ZecSendExt.ZecSendValidation.Invalid.ValidationError>>(emptySet())
}
// TODO [#826]: SendArgumentsWrapper object properties validation
// TODO [#826]: https://github.com/Electric-Coin-Company/zashi-android/issues/826
if (sendArgumentsWrapper?.recipientAddress != null) {
recipientAddressString = sendArgumentsWrapper.recipientAddress
}
if (sendArgumentsWrapper?.amount != null) {
amountZecString = sendArgumentsWrapper.amount
}
if (sendArgumentsWrapper?.memo != null) {
memoString = sendArgumentsWrapper.memo
}
val scrollState = rememberScrollState()
Column(
modifier =
Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
BalanceWidget(
walletSnapshot = walletSnapshot,
@ -368,17 +376,174 @@ private fun SendForm(
onReferenceClick = goBalances
)
Spacer(modifier = Modifier.height(dimens.spacingXlarge))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingXlarge))
// TODO [#1256]: Consider Send.Form TextFields scrolling
// TODO [#1256]: https://github.com/Electric-Coin-Company/zashi-android/issues/1256
SendFormAddressTextField(
focusManager = focusManager,
hasCameraFeature = hasCameraFeature,
onQrScannerOpen = onQrScannerOpen,
recipientAddressState = recipientAddressState,
setRecipientAddress = onRecipientAddressChange
)
Spacer(Modifier.size(ZcashTheme.dimens.spacingDefault))
SendFormAmountTextField(
amountSate = amountState,
setAmountState = setAmountState,
monetarySeparators = monetarySeparators,
focusManager = focusManager,
walletSnapshot = walletSnapshot,
imeAction =
if (recipientAddressState.type == AddressType.Transparent) {
ImeAction.Done
} else {
ImeAction.Next
}
)
Spacer(Modifier.size(ZcashTheme.dimens.spacingDefault))
SendFormMemoTextField(
memoState = memoState,
setMemoState = setMemoState,
focusManager = focusManager,
isMemoFieldAvailable = (
recipientAddressState.address.isEmpty() ||
recipientAddressState.type is AddressType.Invalid ||
(
recipientAddressState.type is AddressType.Valid &&
recipientAddressState.type !is AddressType.Transparent
)
),
)
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
// Common conditions continuously checked for validity
val sendButtonEnabled =
recipientAddressState.type !is AddressType.Invalid &&
recipientAddressState.address.isNotEmpty() &&
amountState is AmountState.Valid &&
amountState.value.isNotBlank() &&
walletSnapshot.spendableBalance() >= (amountState.zatoshi + ZcashSdk.MINERS_FEE) &&
// A valid memo is necessary only for non-transparent recipient
(recipientAddressState.type == AddressType.Transparent || memoState is MemoState.Correct)
Column(
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular),
horizontalAlignment = Alignment.CenterHorizontally
) {
PrimaryButton(
onClick = {
// SDK side validations
val zecSendValidation =
ZecSendExt.new(
context = context,
destinationString = recipientAddressState.address,
zecString = amountState.value,
// Take memo for a valid non-transparent receiver only
memoString =
if (recipientAddressState.type == AddressType.Transparent) {
""
} else {
memoState.text
},
monetarySeparators = monetarySeparators
)
when (zecSendValidation) {
is ZecSendExt.ZecSendValidation.Valid -> onCreateZecSend(zecSendValidation.zecSend)
is ZecSendExt.ZecSendValidation.Invalid -> {
// We do not expect this validation to fail, so logging is enough here
// An error popup could be reasonable here as well
Twig.warn { "Send failed with: ${zecSendValidation.validationErrors}" }
}
}
},
text = stringResource(id = R.string.send_create),
enabled = sendButtonEnabled,
modifier = Modifier.testTag(SendTag.SEND_FORM_BUTTON)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
BodySmall(
text =
stringResource(
id = R.string.send_fee,
// TODO [#1047]: Representing Zatoshi amount
// TODO [#1047]: https://github.com/Electric-Coin-Company/zashi-android/issues/1047
@Suppress("MagicNumber")
Zatoshi(100_000L).toZecString()
),
textFontWeight = FontWeight.SemiBold
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
fun SendFormAddressTextField(
focusManager: FocusManager,
hasCameraFeature: Boolean,
onQrScannerOpen: () -> Unit,
recipientAddressState: RecipientAddressState,
setRecipientAddress: (String) -> Unit,
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Column(
modifier =
Modifier
// Animate error show/hide
.animateContentSize()
// Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester)
) {
Body(text = stringResource(id = R.string.send_address_label))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
val recipientAddressValue = recipientAddressState.address
val recipientAddressError =
if (
recipientAddressValue.isNotEmpty() &&
recipientAddressState.type is AddressType.Invalid
) {
stringResource(id = R.string.send_address_invalid)
} else {
null
}
FormTextField(
value = recipientAddressString,
value = recipientAddressValue,
onValueChange = {
recipientAddressString = it
setRecipientAddress(it)
},
label = { Text(stringResource(id = R.string.send_to)) },
modifier =
Modifier
.fillMaxWidth(),
error = recipientAddressError,
placeholder = {
Text(
text = stringResource(id = R.string.send_address_hint),
style = ZcashTheme.extendedTypography.textFieldHint,
color = ZcashTheme.colors.textFieldHint
)
},
trailingIcon =
if (hasCameraFeature) {
{
@ -386,7 +551,7 @@ private fun SendForm(
onClick = onQrScannerOpen,
content = {
Icon(
imageVector = Icons.Outlined.QrCodeScanner,
painter = painterResource(id = R.drawable.qr_code_icon),
contentDescription = stringResource(R.string.send_scan_content_description)
)
}
@ -405,54 +570,152 @@ private fun SendForm(
onNext = {
focusManager.moveFocus(FocusDirection.Down)
}
)
),
bringIntoViewRequester = bringIntoViewRequester,
)
}
}
Spacer(Modifier.size(dimens.spacingSmall))
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongParameterList", "LongMethod")
@Composable
fun SendFormAmountTextField(
amountSate: AmountState,
focusManager: FocusManager,
monetarySeparators: MonetarySeparators,
setAmountState: (AmountState) -> Unit,
walletSnapshot: WalletSnapshot,
imeAction: ImeAction,
) {
val context = LocalContext.current
val zcashCurrency = ZcashCurrency.getLocalizedName(context)
val amountError =
when (amountSate) {
is AmountState.Invalid -> {
if (amountSate.value.isEmpty()) {
null
} else {
stringResource(id = R.string.send_amount_invalid)
}
}
is AmountState.Valid -> {
if (walletSnapshot.spendableBalance() < (amountSate.zatoshi + ZcashSdk.MINERS_FEE)) {
stringResource(id = R.string.send_amount_insufficient_balance)
} else {
null
}
}
}
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Column(
modifier =
Modifier
// Animate error show/hide
.animateContentSize()
// Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester)
) {
Body(text = stringResource(id = R.string.send_amount_label))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
FormTextField(
value = amountZecString,
value = amountSate.value,
onValueChange = { newValue ->
val validated =
runCatching {
ZecStringExt.filterContinuous(context, monetarySeparators, newValue)
}.onFailure {
Twig.error(it) { "Failed while filtering incoming characters in filterContinuous" }
return@FormTextField
}.getOrDefault(false)
if (!validated) {
return@FormTextField
}
amountZecString = newValue.filter { allowedCharacters.contains(it) }
setAmountState(AmountState.new(context, newValue, monetarySeparators))
},
modifier = Modifier.fillMaxWidth(),
error = amountError,
placeholder = {
Text(
text =
stringResource(
id = R.string.send_amount_hint,
zcashCurrency
),
style = ZcashTheme.extendedTypography.textFieldHint,
color = ZcashTheme.colors.textFieldHint
)
},
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
imeAction = imeAction
),
keyboardActions =
KeyboardActions(
onNext = {
focusManager.moveFocus(FocusDirection.Down)
if (imeAction == ImeAction.Done) {
focusManager.clearFocus(true)
} else {
focusManager.moveFocus(FocusDirection.Down)
}
}
),
label = { Text(stringResource(id = R.string.send_amount)) },
modifier = Modifier.fillMaxWidth()
bringIntoViewRequester = bringIntoViewRequester,
)
}
}
Spacer(Modifier.size(dimens.spacingSmall))
// TODO [#1259]: Send.Form screen Memo field stroke bubble style
// TODO [#1259]: https://github.com/Electric-Coin-Company/zashi-android/issues/1259
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
fun SendFormMemoTextField(
focusManager: FocusManager,
isMemoFieldAvailable: Boolean,
memoState: MemoState,
setMemoState: (MemoState) -> Unit,
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Column(
modifier =
Modifier
// Animate error show/hide
.animateContentSize()
// Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.send_papre_plane),
contentDescription = null,
tint =
if (isMemoFieldAvailable) {
ZcashTheme.colors.textCommon
} else {
ZcashTheme.colors.textDisabled
}
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall))
Body(
text = stringResource(id = R.string.send_memo_label),
color =
if (isMemoFieldAvailable) {
ZcashTheme.colors.textCommon
} else {
ZcashTheme.colors.textDisabled
}
)
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
// TODO [#810]: Disable Memo UI field in case of Transparent address
// TODO [#810]: https://github.com/Electric-Coin-Company/zashi-android/issues/810
FormTextField(
value = memoString,
enabled = isMemoFieldAvailable,
value = memoState.text,
onValueChange = {
if (Memo.isWithinMaxLength(it)) {
memoString = it
}
setMemoState(MemoState.new(it))
},
bringIntoViewRequester = bringIntoViewRequester,
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Text,
@ -464,63 +727,39 @@ private fun SendForm(
focusManager.clearFocus(true)
}
),
label = { Text(stringResource(id = R.string.send_memo)) },
modifier = Modifier.fillMaxWidth()
placeholder = {
Text(
text = stringResource(id = R.string.send_memo_hint),
style = ZcashTheme.extendedTypography.textFieldHint,
color = ZcashTheme.colors.textFieldHint
)
},
modifier = Modifier.fillMaxWidth(),
minHeight = ZcashTheme.dimens.textFieldMemoPanelDefaultHeight,
)
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(
text = validation.joinToString(", "),
modifier = Modifier.fillMaxWidth()
if (isMemoFieldAvailable) {
Body(
text =
stringResource(
id = R.string.send_memo_bytes_counter,
Memo.MAX_MEMO_LENGTH_BYTES - memoState.byteSize,
Memo.MAX_MEMO_LENGTH_BYTES
),
textFontWeight = FontWeight.Bold,
color =
if (memoState is MemoState.Correct) {
ZcashTheme.colors.textFieldHint
} else {
ZcashTheme.colors.textFieldError
},
textAlign = TextAlign.End,
modifier =
Modifier
.fillMaxWidth()
.padding(top = ZcashTheme.dimens.spacingTiny)
)
}
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
Spacer(modifier = Modifier.height(dimens.spacingDefault))
// Create a send amount that is continuously checked for validity
val sendValueCheck = (Zatoshi.fromZecString(context, amountZecString, monetarySeparators))?.value ?: 0L
// Continuous amount check while user is typing into the amount field
// Note: the check for ABBREVIATION_INDEX goes away once proper address validation is in place.
// For now, it just prevents a crash on the confirmation screen.
val sendButtonEnabled =
amountZecString.isNotBlank() &&
sendValueCheck > 0L &&
walletSnapshot.spendableBalance().value >= (sendValueCheck + ZcashSdk.MINERS_FEE.value) &&
recipientAddressString.length > ABBREVIATION_INDEX
PrimaryButton(
onClick = {
val zecSendValidation =
ZecSendExt.new(
context,
recipientAddressString,
amountZecString,
memoString,
monetarySeparators
)
when (zecSendValidation) {
is ZecSendExt.ZecSendValidation.Valid -> onCreateZecSend(zecSendValidation.zecSend)
is ZecSendExt.ZecSendValidation.Invalid -> validation = zecSendValidation.validationErrors
}
},
text = stringResource(id = R.string.send_create),
enabled = sendButtonEnabled,
modifier = Modifier.testTag(SendTag.SEND_FORM_BUTTON)
)
}
}
@ -560,14 +799,14 @@ private fun SendConfirmation(
PrimaryButton(
modifier =
Modifier
.padding(top = dimens.spacingSmall)
.padding(top = ZcashTheme.dimens.spacingSmall)
.testTag(SendTag.SEND_CONFIRMATION_BUTTON),
onClick = onConfirmation,
text = stringResource(id = R.string.send_confirmation_button),
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
}
@ -614,7 +853,7 @@ private fun Sending(
Body(
modifier =
Modifier
.padding(vertical = dimens.spacingSmall)
.padding(vertical = ZcashTheme.dimens.spacingSmall)
.fillMaxWidth(),
text = stringResource(R.string.send_in_progress_wait),
textAlign = TextAlign.Center,
@ -642,7 +881,7 @@ private fun SendSuccessful(
modifier =
Modifier
.fillMaxWidth()
.height(dimens.spacingDefault)
.height(ZcashTheme.dimens.spacingDefault)
)
Body(
@ -664,14 +903,14 @@ private fun SendSuccessful(
PrimaryButton(
modifier =
Modifier
.padding(top = dimens.spacingSmall)
.padding(top = ZcashTheme.dimens.spacingSmall)
.testTag(SendTag.SEND_SUCCESS_BUTTON),
text = stringResource(R.string.send_successful_button),
onClick = onDone,
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
}
@ -696,7 +935,7 @@ private fun SendFailure(
modifier =
Modifier
.fillMaxWidth()
.height(dimens.spacingDefault)
.height(ZcashTheme.dimens.spacingDefault)
)
Body(
@ -712,7 +951,7 @@ private fun SendFailure(
modifier =
Modifier
.fillMaxWidth()
.height(dimens.spacingDefault)
.height(ZcashTheme.dimens.spacingDefault)
)
Body(
@ -732,13 +971,13 @@ private fun SendFailure(
PrimaryButton(
modifier =
Modifier
.padding(top = dimens.spacingSmall)
.padding(top = ZcashTheme.dimens.spacingSmall)
.testTag(SendTag.SEND_FAILED_BUTTON),
text = stringResource(R.string.send_failure_button),
onClick = onDone,
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
}

View File

@ -146,7 +146,7 @@ private fun SupportMainContent(
modifier =
Modifier
.fillMaxWidth(),
label = { Text(text = stringResource(id = R.string.support_hint)) }
placeholder = { Text(text = stringResource(id = R.string.support_hint)) },
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))

View File

@ -10,7 +10,8 @@
<string name="balances_transparent_balance_help_close">I got it!</string>
<string name="balances_transparent_help_content_description">Show help</string>
<string name="balances_transparent_balance_shield">Shield and consolidate funds</string>
<string name="balances_transparent_balance_fee">(Fee &lt; <xliff:g id="fee_amount" example="0.001">%1$s</xliff:g>)</string>
<string name="balances_transparent_balance_fee">(Typical fee &lt; <xliff:g id="fee_amount" example="0.001">%1$s
</xliff:g>)</string>
<string name="balances_status_syncing" formatted="true">Syncing…</string>
<string name="balances_status_syncing_amount_suffix" formatted="true"><xliff:g id="amount_prefix" example="123$">%1$s</xliff:g> so far</string>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="26dp"
android:viewportWidth="26"
android:viewportHeight="26">
<path
android:pathData="M16.727,11.88H22.906C24.644,11.88 25.5,11.01 25.5,9.218V3.148C25.5,1.355 24.644,0.5 22.906,0.5H16.727C15.003,0.5 14.134,1.355 14.134,3.148V9.218C14.134,11.011 15.003,11.88 16.727,11.88ZM3.094,11.88H9.286C11.01,11.88 11.88,11.01 11.88,9.218V3.148C11.88,1.355 11.01,0.5 9.286,0.5H3.094C1.369,0.5 0.5,1.355 0.5,3.148V9.218C0.5,11.011 1.369,11.88 3.094,11.88ZM3.121,9.965C2.645,9.965 2.415,9.72 2.415,9.218V3.148C2.415,2.659 2.645,2.415 3.121,2.415H9.245C9.72,2.415 9.965,2.659 9.965,3.148V9.218C9.965,9.721 9.72,9.965 9.245,9.965H3.121ZM16.755,9.965C16.279,9.965 16.049,9.72 16.049,9.218V3.148C16.049,2.659 16.279,2.415 16.755,2.415H22.893C23.354,2.415 23.585,2.659 23.585,3.148V9.218C23.585,9.721 23.354,9.965 22.893,9.965H16.755ZM5.022,7.616H7.344C7.548,7.616 7.629,7.534 7.629,7.304V5.049C7.629,4.832 7.548,4.75 7.344,4.75H5.022C4.819,4.75 4.764,4.832 4.764,5.049V7.304C4.764,7.534 4.819,7.616 5.022,7.616ZM18.737,7.616H21.046C21.25,7.616 21.331,7.534 21.331,7.304V5.049C21.331,4.832 21.25,4.75 21.046,4.75H18.737C18.534,4.75 18.466,4.832 18.466,5.049V7.304C18.466,7.534 18.534,7.616 18.737,7.616ZM3.094,25.5H9.286C11.01,25.5 11.88,24.645 11.88,22.852V16.768C11.88,14.99 11.01,14.12 9.286,14.12H3.094C1.369,14.12 0.5,14.99 0.5,16.768V22.852C0.5,24.645 1.369,25.5 3.094,25.5ZM15.043,17.637H17.365C17.569,17.637 17.65,17.556 17.65,17.325V15.071C17.65,14.853 17.569,14.772 17.365,14.772H15.043C14.84,14.772 14.785,14.853 14.785,15.071V17.325C14.785,17.556 14.84,17.637 15.043,17.637ZM22.254,17.637H24.576C24.78,17.637 24.861,17.556 24.861,17.325V15.071C24.861,14.853 24.78,14.772 24.576,14.772H22.254C22.051,14.772 21.983,14.853 21.983,15.071V17.325C21.983,17.556 22.05,17.637 22.254,17.637ZM3.121,23.585C2.645,23.585 2.415,23.341 2.415,22.852V16.782C2.415,16.28 2.645,16.035 3.121,16.035H9.245C9.72,16.035 9.965,16.28 9.965,16.782V22.852C9.965,23.341 9.72,23.585 9.245,23.585H3.121ZM5.022,21.25H7.344C7.548,21.25 7.629,21.168 7.629,20.924V18.683C7.629,18.465 7.548,18.384 7.344,18.384H5.022C4.819,18.384 4.764,18.465 4.764,18.683V20.924C4.764,21.168 4.819,21.25 5.022,21.25ZM18.683,21.25H21.005C21.209,21.25 21.29,21.168 21.29,20.924V18.683C21.29,18.465 21.209,18.384 21.005,18.384H18.683C18.479,18.384 18.425,18.465 18.425,18.683V20.924C18.425,21.168 18.479,21.25 18.683,21.25ZM15.043,24.848H17.365C17.569,24.848 17.65,24.766 17.65,24.535V22.281C17.65,22.064 17.569,21.983 17.365,21.983H15.043C14.84,21.983 14.785,22.064 14.785,22.281V24.535C14.785,24.766 14.84,24.848 15.043,24.848ZM22.254,24.848H24.576C24.78,24.848 24.861,24.766 24.861,24.535V22.281C24.861,22.064 24.78,21.983 24.576,21.983H22.254C22.051,21.983 21.983,22.064 21.983,22.281V24.535C21.983,24.766 22.05,24.848 22.254,24.848Z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="16dp"
android:viewportWidth="20"
android:viewportHeight="16">
<group>
<clip-path
android:pathData="M0,0h20v16h-20z"/>
<path
android:pathData="M19.92,0.07C19.81,-0.03 19.5,0 19.31,0.06C13.04,2.2 6.77,4.34 0.5,6.5C0.31,6.56 0.17,6.75 0,6.88C0.13,7.04 0.24,7.25 0.41,7.35C1.57,8.05 2.76,8.72 3.92,9.43C4.15,9.57 4.37,9.85 4.44,10.11C4.93,11.9 5.38,13.7 5.86,15.49C5.91,15.68 6.05,15.96 6.2,15.99C6.35,16.02 6.6,15.85 6.74,15.7C7.68,14.75 8.62,13.79 9.53,12.82C9.79,12.54 9.99,12.48 10.35,12.66C11.64,13.32 12.95,13.93 14.25,14.56C14.39,14.63 14.53,14.69 14.77,14.79C14.89,14.6 15.04,14.43 15.12,14.23C16.74,9.71 18.35,5.18 19.96,0.65C20.02,0.47 20.04,0.16 19.93,0.06L19.92,0.07ZM4.26,8.81C3.28,8.21 2.28,7.64 1.2,7C6.3,5.25 11.28,3.55 16.26,1.84L16.3,1.92C16.16,2.01 16.03,2.1 15.89,2.18C12.27,4.38 8.66,6.57 5.04,8.78C4.76,8.95 4.55,8.99 4.26,8.8V8.81ZM6.71,10.97C6.53,11.87 6.39,12.77 6.12,13.68C6.02,13.3 5.92,12.93 5.82,12.55C5.6,11.69 5.38,10.84 5.15,9.98C5.09,9.76 5.06,9.58 5.32,9.43C8.56,7.48 11.79,5.51 15.03,3.55C15.07,3.53 15.12,3.53 15.3,3.49C14.16,4.44 13.13,5.3 12.09,6.16C11.63,6.54 8.6,8.99 8.14,9.38C8.07,9.44 8,9.5 7.95,9.57C7.62,9.81 7.3,10.08 7.01,10.37C6.85,10.52 6.75,10.77 6.71,10.98V10.97ZM6.81,14.61C7.01,13.42 7.19,12.38 7.38,11.25C8,11.55 8.58,11.83 9.2,12.13C8.41,12.95 7.65,13.73 6.8,14.61H6.81ZM14.48,13.9C12.24,12.81 10.04,11.75 7.77,10.65C8.09,10.38 8.39,10.18 8.62,9.92C11.01,7.93 15.96,3.88 18.35,1.89C18.49,1.77 18.63,1.66 18.86,1.6C17.41,5.68 15.95,9.77 14.48,13.9Z"
android:fillColor="#000000"/>
</group>
</vector>

View File

@ -3,10 +3,21 @@
<string name="send_back">Back</string>
<string name="send_back_content_description">Back</string>
<string name="send_scan_content_description">Scan</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_address_label">To:</string>
<string name="send_address_hint">Zcash Address</string>
<string name="send_address_invalid">Invalid address</string>
<string name="send_amount_label">Amount:</string>
<string name="send_amount_hint"><xliff:g id="currency" example="ZEC">%1$s</xliff:g> Amount</string>
<string name="send_amount_insufficient_balance">Insufficient funds</string>
<string name="send_amount_invalid">Invalid amount</string>
<string name="send_memo_label">Message</string>
<string name="send_memo_hint">Write private message here…</string>
<string name="send_memo_bytes_counter">
<xliff:g id="typed_bytes" example="12">%1$s</xliff:g>/
<xliff:g id="max_bytes" example="500">%2$s</xliff:g>
</string>
<string name="send_create">Preview</string>
<string name="send_fee">(Typical fee &lt; <xliff:g id="fee_amount" example="0.001">%1$s</xliff:g>)</string>
<string name="send_confirmation_amount_and_address_format" formatted="true">Send <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC to <xliff:g id="address" example="zs1g7cqw … mvyzgm">%2$s</xliff:g>?</string>
<string name="send_confirmation_memo_format" formatted="true">Memo: <xliff:g id="memo" example="for Veronika">%1$s</xliff:g></string>

View File

@ -33,6 +33,7 @@ import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.MainActivity
@ -485,17 +486,22 @@ private fun sendZecScreenshots(
// Screenshot: Empty form
ScreenshotTest.takeScreenshot(tag, "Send 1")
composeTestRule.onNodeWithText(resContext.getString(R.string.send_amount)).also {
composeTestRule.onNodeWithText(
resContext.getString(
R.string.send_amount_hint,
ZcashCurrency.fromResources(resContext).name
)
).also {
val separators = MonetarySeparators.current()
it.performTextInput("0${separators.decimal}123")
}
composeTestRule.onNodeWithText(resContext.getString(R.string.send_to)).also {
composeTestRule.onNodeWithText(resContext.getString(R.string.send_address_hint)).also {
it.performTextInput(WalletAddressFixture.UNIFIED_ADDRESS_STRING)
}
composeTestRule.onNodeWithText(resContext.getString(R.string.send_memo)).also {
composeTestRule.onNodeWithText(resContext.getString(R.string.send_memo_hint)).also {
it.performTextInput(MemoFixture.MEMO_STRING)
}