[#1159] Send.Confirmation according to new design

- Closes #1159
- Closes #1269
- Closes #1073
- Its direct follow-ups are #1294 and #1161
- Other follow up is #1260
- These changes also enable having two Primary buttons side-by-side
- This adds sorting history of transactions by a new calculated height after send done
- This also changes how we treat empty transaction dates from `-` to `` in UI
- Changelog update
This commit is contained in:
Honza Rychnovský 2024-03-21 09:57:36 +01:00 committed by GitHub
parent 24cd22186f
commit 3845772071
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1007 additions and 730 deletions

View File

@ -15,9 +15,13 @@ directly impact users rather than highlighting other key architectural updates.*
- A new Server switching screen was added. Its purpose is to enable switching between predefined and custom - A new Server switching screen was added. Its purpose is to enable switching between predefined and custom
lightwalletd servers in runtime. lightwalletd servers in runtime.
- The About screen now contains a link to the new Zashi Privacy Policy website - The About screen now contains a link to the new Zashi Privacy Policy website
- The Send Confirmation screen has been reworked according to the new design
### Changed ### Changed
- The Transaction History UI has been incorporated into the Account screen - The Transaction History UI has been incorporated into the Account screen
- Reworked Send screens flow and their look (e.g., Send Failure screen is now a modal dialog instead of a separate
screen)
- The sending and shielding funds logic has been connected to the new Proposal API from the Zcash SDK
### Fixed ### Fixed
- Button sizing has been updated to align with the design guidelines and preserve stretching if necessary - Button sizing has been updated to align with the design guidelines and preserve stretching if necessary

View File

@ -158,7 +158,7 @@ ANDROIDX_CONSTRAINTLAYOUT_VERSION=1.0.1
ANDROIDX_CORE_VERSION=1.9.0 ANDROIDX_CORE_VERSION=1.9.0
ANDROIDX_ESPRESSO_VERSION=3.5.1 ANDROIDX_ESPRESSO_VERSION=3.5.1
ANDROIDX_LIFECYCLE_VERSION=2.6.2 ANDROIDX_LIFECYCLE_VERSION=2.6.2
ANDROIDX_NAVIGATION_COMPOSE_VERSION=2.7.5 ANDROIDX_NAVIGATION_COMPOSE_VERSION=2.7.7
ANDROIDX_PROFILE_INSTALLER_VERSION=1.3.1 ANDROIDX_PROFILE_INSTALLER_VERSION=1.3.1
ANDROIDX_SECURITY_CRYPTO_VERSION=1.1.0-alpha06 ANDROIDX_SECURITY_CRYPTO_VERSION=1.1.0-alpha06
ANDROIDX_SPLASH_SCREEN_VERSION=1.0.1 ANDROIDX_SPLASH_SCREEN_VERSION=1.0.1

View File

@ -1,20 +0,0 @@
@file:Suppress("ktlint:standard:filename")
package cash.z.ecc.sdk.extension
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.ZecSend
// TODO [#1285]: Adopt proposal API
// TODO [#1285]: https://github.com/Electric-Coin-Company/zashi-android/issues/1285
@Suppress("deprecation")
suspend fun Synchronizer.send(
spendingKey: UnifiedSpendingKey,
send: ZecSend
) = sendToAddress(
spendingKey,
send.amount,
send.destination.address,
send.memo.value
)

View File

@ -50,6 +50,7 @@ dependencies {
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.androidx.splash) implementation(libs.androidx.splash)
implementation(libs.bundles.androidx.compose.core) implementation(libs.bundles.androidx.compose.core)
implementation(libs.bundles.androidx.compose.extended)
implementation(libs.kotlin.stdlib) implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)

View File

@ -0,0 +1,47 @@
package co.electriccoin.zcash.ui.design.animation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.IntOffset
import androidx.navigation.NavBackStackEntry
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
object ScreenAnimation {
private const val DURATION = 400
fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() =
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
initialOffset = { it },
animationSpec = tween(duration = DURATION.milliseconds)
)
fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
targetOffset = { it },
animationSpec = tween(duration = DURATION.milliseconds)
)
fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
initialOffset = { it },
animationSpec = tween(duration = DURATION.milliseconds)
)
fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
targetOffset = { it },
animationSpec = tween(duration = DURATION.milliseconds)
)
}
private fun tween(duration: Duration): TweenSpec<IntOffset> =
tween(
durationMillis = duration.toInt(DurationUnit.MILLISECONDS)
)

View File

@ -7,6 +7,7 @@ import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -57,6 +58,12 @@ private fun ButtonComposablePreview() {
TertiaryButton(onClick = { }, text = "Tertiary", enabled = false) TertiaryButton(onClick = { }, text = "Tertiary", enabled = false)
NavigationButton(onClick = { }, text = "Navigation") NavigationButton(onClick = { }, text = "Navigation")
DangerousButton(onClick = { }, text = "Dangerous") DangerousButton(onClick = { }, text = "Dangerous")
@Suppress("MagicNumber")
Row {
PrimaryButton(onClick = { }, text = "Button 1", modifier = Modifier.weight(0.5f))
Spacer(modifier = Modifier.width(24.dp))
PrimaryButton(onClick = { }, text = "Button 2", modifier = Modifier.weight(0.5f))
}
} }
} }
} }
@ -80,7 +87,7 @@ fun PrimaryButton(
horizontal = ZcashTheme.dimens.spacingNone, horizontal = ZcashTheme.dimens.spacingNone,
vertical = ZcashTheme.dimens.spacingSmall vertical = ZcashTheme.dimens.spacingSmall
), ),
contentPaddingValues: PaddingValues = PaddingValues(all = 16.dp) contentPaddingValues: PaddingValues = PaddingValues(all = 14.dp)
) { ) {
Button( Button(
shape = RectangleShape, shape = RectangleShape,
@ -104,7 +111,6 @@ fun PrimaryButton(
translationY = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp translationY = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp
) )
.defaultMinSize(minWidth, minHeight) .defaultMinSize(minWidth, minHeight)
.fillMaxWidth()
.border(1.dp, Color.Black) .border(1.dp, Color.Black)
), ),
colors = colors =

View File

@ -222,7 +222,7 @@ fun Tiny(
overflow = overflow, overflow = overflow,
textAlign = textAlign, textAlign = textAlign,
modifier = modifier, modifier = modifier,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.bodySmall,
) )
} }

View File

@ -14,6 +14,7 @@ data class Dimens(
val spacingXtiny: Dp, val spacingXtiny: Dp,
val spacingTiny: Dp, val spacingTiny: Dp,
val spacingSmall: Dp, val spacingSmall: Dp,
val spacingMid: Dp,
val spacingDefault: Dp, val spacingDefault: Dp,
val spacingLarge: Dp, val spacingLarge: Dp,
val spacingXlarge: Dp, val spacingXlarge: Dp,
@ -58,6 +59,7 @@ private val defaultDimens =
spacingXtiny = 2.dp, spacingXtiny = 2.dp,
spacingTiny = 4.dp, spacingTiny = 4.dp,
spacingSmall = 8.dp, spacingSmall = 8.dp,
spacingMid = 12.dp,
spacingDefault = 16.dp, spacingDefault = 16.dp,
spacingLarge = 24.dp, spacingLarge = 24.dp,
spacingXlarge = 32.dp, spacingXlarge = 32.dp,

View File

@ -253,7 +253,7 @@ val LocalExtendedTypography =
lineHeight = 20.sp lineHeight = 20.sp
), ),
buttonText = buttonText =
PrimaryTypography.bodySmall.copy( PrimaryTypography.bodyMedium.copy(
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
), ),
buttonTextSmall = buttonTextSmall =

View File

@ -46,6 +46,7 @@ android {
"src/main/res/ui/scan", "src/main/res/ui/scan",
"src/main/res/ui/seed_recovery", "src/main/res/ui/seed_recovery",
"src/main/res/ui/send", "src/main/res/ui/send",
"src/main/res/ui/send_confirmation",
"src/main/res/ui/settings", "src/main/res/ui/settings",
"src/main/res/ui/support", "src/main/res/ui/support",
"src/main/res/ui/update", "src/main/res/ui/update",

View File

@ -1,33 +1,20 @@
package co.electriccoin.zcash.ui.fixture package co.electriccoin.zcash.ui.fixture
import cash.z.ecc.android.sdk.fixture.WalletFixture 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.ZcashNetwork
import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.android.sdk.type.AddressType import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.sdk.fixture.MemoFixture import co.electriccoin.zcash.ui.common.model.SerializableAddress
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 import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
internal object SendArgumentsWrapperFixture { internal object SendArgumentsWrapperFixture {
val RECIPIENT_ADDRESS = val RECIPIENT_ADDRESS =
ScanResult( SerializableAddress(
address = WalletFixture.Alice.getAddresses(ZcashNetwork.Testnet).unified, address = WalletFixture.Alice.getAddresses(ZcashNetwork.Testnet).unified,
type = AddressType.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: SerializableAddress? = RECIPIENT_ADDRESS) =
SendArgumentsWrapper(
fun new( recipientAddress = recipientAddress?.toRecipient(),
recipientAddress: ScanResult? = RECIPIENT_ADDRESS, )
amount: Zatoshi? = AMOUNT,
memo: String? = MEMO
) = SendArgumentsWrapper(
recipientAddress = recipientAddress?.toRecipient(),
amount = amountToFixtureZecString(amount),
memo = memo
)
} }

View File

@ -13,6 +13,7 @@ import androidx.compose.ui.test.performTextInput
import cash.z.ecc.sdk.fixture.ZecSendFixture import cash.z.ecc.sdk.fixture.ZecSendFixture
import cash.z.ecc.sdk.type.ZcashCurrency import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.sendconfirmation.SendConfirmationTag
import co.electriccoin.zcash.ui.test.getAppContext import co.electriccoin.zcash.ui.test.getAppContext
import co.electriccoin.zcash.ui.test.getStringResource import co.electriccoin.zcash.ui.test.getStringResource
import co.electriccoin.zcash.ui.test.getStringResourceWithArgs import co.electriccoin.zcash.ui.test.getStringResourceWithArgs
@ -94,8 +95,8 @@ internal fun ComposeContentTestRule.clickCreateAndSend() {
} }
} }
internal fun ComposeContentTestRule.clickConfirmation() { internal fun ComposeContentTestRule.dismissFailureDialog() {
onNodeWithTag(SendTag.SEND_CONFIRMATION_BUTTON).also { onNodeWithText(getStringResource(R.string.send_dialog_error_btn)).also {
it.performClick() it.performClick()
} }
} }
@ -107,25 +108,13 @@ internal fun ComposeContentTestRule.assertOnForm() {
} }
internal fun ComposeContentTestRule.assertOnConfirmation() { internal fun ComposeContentTestRule.assertOnConfirmation() {
onNodeWithTag(SendTag.SEND_CONFIRMATION_BUTTON).also { onNodeWithTag(SendConfirmationTag.SEND_CONFIRMATION_SEND_BUTTON).also {
it.assertExists()
}
}
internal fun ComposeContentTestRule.assertOnSending() {
onNodeWithText(getStringResource(R.string.send_in_progress_wait)).also {
it.assertExists()
}
}
internal fun ComposeContentTestRule.assertOnSendSuccessful() {
onNodeWithText(getStringResource(R.string.send_successful_title)).also {
it.assertExists() it.assertExists()
} }
} }
internal fun ComposeContentTestRule.assertOnSendFailure() { internal fun ComposeContentTestRule.assertOnSendFailure() {
onNodeWithText(getStringResource(R.string.send_failure_title)).also { onNodeWithText(getStringResource(R.string.send_dialog_error_title)).also {
it.assertExists() it.assertExists()
} }
} }

View File

@ -21,7 +21,6 @@ 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.model.SendStage
import co.electriccoin.zcash.ui.screen.send.view.Send import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.util.Locale import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@ -91,10 +90,8 @@ class SendViewTestSetup(
onBackCount.incrementAndGet() onBackCount.incrementAndGet()
when (sendStage) { when (sendStage) {
SendStage.Form -> {} SendStage.Form -> {}
SendStage.Confirmation -> setSendStage(SendStage.Form) SendStage.Proposing -> {}
SendStage.Sending -> {}
is SendStage.SendFailure -> setSendStage(SendStage.Form) is SendStage.SendFailure -> setSendStage(SendStage.Form)
SendStage.SendSuccessful -> {}
} }
} }
@ -119,17 +116,11 @@ class SendViewTestSetup(
) )
), ),
sendStage = sendStage, sendStage = sendStage,
onSendStageChange = setSendStage,
onCreateZecSend = setZecSend, onCreateZecSend = setZecSend,
zecSend = zecSend, zecSend = zecSend,
focusManager = LocalFocusManager.current, focusManager = LocalFocusManager.current,
onBack = onBackAction, onBack = onBackAction,
onSettings = { onSettingsCount.incrementAndGet() }, onSettings = { onSettingsCount.incrementAndGet() },
onCreateAndSend = {
onCreateCount.incrementAndGet()
lastZecSend = it
mutableActionExecuted.update { true }
},
onQrScannerOpen = { onQrScannerOpen = {
onScannerCount.incrementAndGet() onScannerCount.incrementAndGet()
}, },

View File

@ -77,7 +77,8 @@ class SendViewIntegrationTest {
goBalances = {}, goBalances = {},
hasCameraFeature = true, hasCameraFeature = true,
goSettings = {}, goSettings = {},
monetarySeparators = monetarySeparators monetarySeparators = monetarySeparators,
goSendConfirmation = {}
) )
} }

View File

@ -7,12 +7,10 @@ import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.sdk.fixture.ZecSendFixture import cash.z.ecc.sdk.fixture.ZecSendFixture
import co.electriccoin.zcash.test.UiTestPrerequisites import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup
import co.electriccoin.zcash.ui.screen.send.assertOnConfirmation
import co.electriccoin.zcash.ui.screen.send.assertOnForm import co.electriccoin.zcash.ui.screen.send.assertOnForm
import co.electriccoin.zcash.ui.screen.send.assertOnSendFailure import co.electriccoin.zcash.ui.screen.send.assertOnSendFailure
import co.electriccoin.zcash.ui.screen.send.assertOnSendSuccessful import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend
import co.electriccoin.zcash.ui.screen.send.assertOnSending import co.electriccoin.zcash.ui.screen.send.dismissFailureDialog
import co.electriccoin.zcash.ui.screen.send.clickConfirmation
import co.electriccoin.zcash.ui.screen.send.model.SendStage import co.electriccoin.zcash.ui.screen.send.model.SendStage
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Rule import org.junit.Rule
@ -42,37 +40,18 @@ class SendViewAndroidTest : UiTestPrerequisites() {
fun back_on_sending_with_system_navigation_disabled_check() { fun back_on_sending_with_system_navigation_disabled_check() {
val testSetup = val testSetup =
newTestSetup( newTestSetup(
SendStage.Confirmation, SendStage.Form,
runBlocking { ZecSendFixture.new() } runBlocking { ZecSendFixture.new() }
) )
assertEquals(0, testSetup.getOnBackCount()) assertEquals(0, testSetup.getOnBackCount())
composeTestRule.assertOnConfirmation() composeTestRule.assertOnForm()
composeTestRule.clickConfirmation() composeTestRule.clickCreateAndSend()
composeTestRule.assertOnSending()
Espresso.pressBack() Espresso.pressBack()
composeTestRule.assertOnSending() composeTestRule.assertOnForm()
assertEquals(1, testSetup.getOnBackCount())
}
@Test
@MediumTest
fun back_on_send_successful_with_system_navigation() {
val testSetup =
newTestSetup(
SendStage.SendSuccessful,
runBlocking { ZecSendFixture.new() }
)
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.assertOnSendSuccessful()
Espresso.pressBack()
assertEquals(1, testSetup.getOnBackCount()) assertEquals(1, testSetup.getOnBackCount())
} }
@ -90,7 +69,7 @@ class SendViewAndroidTest : UiTestPrerequisites() {
composeTestRule.assertOnSendFailure() composeTestRule.assertOnSendFailure()
Espresso.pressBack() composeTestRule.dismissFailureDialog()
composeTestRule.assertOnForm() composeTestRule.assertOnForm()

View File

@ -6,7 +6,6 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import cash.z.ecc.android.sdk.ext.collectWith import cash.z.ecc.android.sdk.ext.collectWith
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
@ -20,20 +19,17 @@ import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.fixture.SendArgumentsWrapperFixture import co.electriccoin.zcash.ui.fixture.SendArgumentsWrapperFixture
import co.electriccoin.zcash.ui.screen.send.SendTag import co.electriccoin.zcash.ui.screen.send.SendTag
import co.electriccoin.zcash.ui.screen.send.SendTag.SEND_FAILED_BUTTON
import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup
import co.electriccoin.zcash.ui.screen.send.assertOnConfirmation import co.electriccoin.zcash.ui.screen.send.assertOnConfirmation
import co.electriccoin.zcash.ui.screen.send.assertOnForm import co.electriccoin.zcash.ui.screen.send.assertOnForm
import co.electriccoin.zcash.ui.screen.send.assertOnSendFailure import co.electriccoin.zcash.ui.screen.send.assertOnSendFailure
import co.electriccoin.zcash.ui.screen.send.assertOnSendSuccessful
import co.electriccoin.zcash.ui.screen.send.assertOnSending
import co.electriccoin.zcash.ui.screen.send.assertSendDisabled import co.electriccoin.zcash.ui.screen.send.assertSendDisabled
import co.electriccoin.zcash.ui.screen.send.assertSendEnabled import co.electriccoin.zcash.ui.screen.send.assertSendEnabled
import co.electriccoin.zcash.ui.screen.send.clickBack import co.electriccoin.zcash.ui.screen.send.clickBack
import co.electriccoin.zcash.ui.screen.send.clickConfirmation
import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend
import co.electriccoin.zcash.ui.screen.send.clickScanner import co.electriccoin.zcash.ui.screen.send.clickScanner
import co.electriccoin.zcash.ui.screen.send.clickSettingsTopAppBarMenu import co.electriccoin.zcash.ui.screen.send.clickSettingsTopAppBarMenu
import co.electriccoin.zcash.ui.screen.send.dismissFailureDialog
import co.electriccoin.zcash.ui.screen.send.model.SendStage import co.electriccoin.zcash.ui.screen.send.model.SendStage
import co.electriccoin.zcash.ui.screen.send.setAmount import co.electriccoin.zcash.ui.screen.send.setAmount
import co.electriccoin.zcash.ui.screen.send.setMemo import co.electriccoin.zcash.ui.screen.send.setMemo
@ -95,7 +91,6 @@ class SendViewTest : UiTestPrerequisites() {
composeTestRule.setValidAddress() composeTestRule.setValidAddress()
composeTestRule.clickCreateAndSend() composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation() composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
launch { launch {
testSetup.mutableActionExecuted.collectWith(this) { testSetup.mutableActionExecuted.collectWith(this) {
@ -132,7 +127,6 @@ class SendViewTest : UiTestPrerequisites() {
composeTestRule.clickCreateAndSend() composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation() composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
launch { launch {
testSetup.mutableActionExecuted.collectWith(this) { testSetup.mutableActionExecuted.collectWith(this) {
@ -191,7 +185,6 @@ class SendViewTest : UiTestPrerequisites() {
composeTestRule.clickCreateAndSend() composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation() composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
launch { launch {
testSetup.mutableActionExecuted.collectWith(this) { testSetup.mutableActionExecuted.collectWith(this) {
@ -273,7 +266,6 @@ class SendViewTest : UiTestPrerequisites() {
composeTestRule.clickCreateAndSend() composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation() composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
launch { launch {
testSetup.mutableActionExecuted.collectWith(this) { testSetup.mutableActionExecuted.collectWith(this) {
@ -324,60 +316,6 @@ class SendViewTest : UiTestPrerequisites() {
assertEquals(1, testSetup.getOnBackCount()) assertEquals(1, testSetup.getOnBackCount())
} }
@Test
@MediumTest
fun back_on_sending_disabled_check() {
newTestSetup(
SendStage.Confirmation,
runBlocking { ZecSendFixture.new() }
)
composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
composeTestRule.assertOnSending()
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.send_back_content_description)).also {
it.assertDoesNotExist()
}
}
@Test
@MediumTest
fun back_on_send_successful() {
val testSetup =
newTestSetup(
SendStage.SendSuccessful,
runBlocking { ZecSendFixture.new() }
)
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.assertOnSendSuccessful()
composeTestRule.clickBack()
assertEquals(1, testSetup.getOnBackCount())
}
@Test
@MediumTest
fun close_on_send_successful() {
val testSetup =
newTestSetup(
SendStage.SendSuccessful,
runBlocking { ZecSendFixture.new() }
)
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.assertOnSendSuccessful()
composeTestRule.onNodeWithText(getStringResource(R.string.send_successful_button), ignoreCase = true).also {
it.assertExists()
it.performClick()
}
assertEquals(1, testSetup.getOnBackCount())
}
@Test @Test
@MediumTest @MediumTest
fun back_on_send_failure() { fun back_on_send_failure() {
@ -390,7 +328,9 @@ class SendViewTest : UiTestPrerequisites() {
assertEquals(0, testSetup.getOnBackCount()) assertEquals(0, testSetup.getOnBackCount())
composeTestRule.assertOnSendFailure() composeTestRule.assertOnSendFailure()
composeTestRule.clickBack()
composeTestRule.dismissFailureDialog()
composeTestRule.assertOnForm() composeTestRule.assertOnForm()
assertEquals(1, testSetup.getOnBackCount()) assertEquals(1, testSetup.getOnBackCount())
@ -408,10 +348,9 @@ class SendViewTest : UiTestPrerequisites() {
assertEquals(0, testSetup.getOnBackCount()) assertEquals(0, testSetup.getOnBackCount())
composeTestRule.assertOnSendFailure() composeTestRule.assertOnSendFailure()
composeTestRule.onNodeWithTag(SEND_FAILED_BUTTON).also {
it.assertExists() composeTestRule.dismissFailureDialog()
it.performClick()
}
composeTestRule.assertOnForm() composeTestRule.assertOnForm()
assertEquals(1, testSetup.getOnBackCount()) assertEquals(1, testSetup.getOnBackCount())
@ -451,20 +390,6 @@ class SendViewTest : UiTestPrerequisites() {
includeEditableText = true includeEditableText = true
) )
} }
composeTestRule.onNodeWithText(getStringResource(R.string.send_amount_hint)).also {
it.assertTextEquals(
getStringResource(R.string.send_amount_hint),
SendArgumentsWrapperFixture.amountToFixtureZecString(SendArgumentsWrapperFixture.AMOUNT)!!,
includeEditableText = true
)
}
composeTestRule.onNodeWithText(getStringResource(R.string.send_memo_hint)).also {
it.assertTextEquals(
getStringResource(R.string.send_memo_hint),
SendArgumentsWrapperFixture.MEMO,
includeEditableText = true
)
}
} }
@Test @Test

View File

@ -6,9 +6,11 @@ import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import co.electriccoin.zcash.ui.NavigationArguments.SEND_AMOUNT import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.SEND_MEMO import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_MEMO
import co.electriccoin.zcash.ui.NavigationArguments.SEND_RECIPIENT_ADDRESS import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_PROPOSAL
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER
@ -17,10 +19,16 @@ import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
import co.electriccoin.zcash.ui.NavigationTargets.SCAN import co.electriccoin.zcash.ui.NavigationTargets.SCAN
import co.electriccoin.zcash.ui.NavigationTargets.SEED_RECOVERY import co.electriccoin.zcash.ui.NavigationTargets.SEED_RECOVERY
import co.electriccoin.zcash.ui.NavigationTargets.SEND_CONFIRMATION
import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.common.model.SerializableAddress
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.enterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.exitTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popEnterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransition
import co.electriccoin.zcash.ui.screen.about.WrapAbout import co.electriccoin.zcash.ui.screen.about.WrapAbout
import co.electriccoin.zcash.ui.screen.advancedsettings.WrapAdvancedSettings import co.electriccoin.zcash.ui.screen.advancedsettings.WrapAdvancedSettings
import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer
@ -28,14 +36,19 @@ import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.home.WrapHome import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.request.WrapRequest import co.electriccoin.zcash.ui.screen.request.WrapRequest
import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator
import co.electriccoin.zcash.ui.screen.scan.model.ScanResult
import co.electriccoin.zcash.ui.screen.seedrecovery.WrapSeedRecovery import co.electriccoin.zcash.ui.screen.seedrecovery.WrapSeedRecovery
import co.electriccoin.zcash.ui.screen.send.ext.toSerializableAddress
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.sendconfirmation.WrapSendConfirmation
import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SendConfirmationArgsWrapper
import co.electriccoin.zcash.ui.screen.settings.WrapSettings import co.electriccoin.zcash.ui.screen.settings.WrapSettings
import co.electriccoin.zcash.ui.screen.support.WrapSupport import co.electriccoin.zcash.ui.screen.support.WrapSupport
import co.electriccoin.zcash.ui.screen.update.WrapCheckForUpdate import co.electriccoin.zcash.ui.screen.update.WrapCheckForUpdate
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
// TODO [#1297]: Consider: Navigation passing complex data arguments different way
// TODO [#1297]: https://github.com/Electric-Coin-Company/zashi-android/issues/1297
@Composable @Composable
@Suppress("LongMethod") @Suppress("LongMethod")
internal fun MainActivity.Navigation() { internal fun MainActivity.Navigation() {
@ -44,30 +57,47 @@ internal fun MainActivity.Navigation() {
navControllerForTesting = it navControllerForTesting = it
} }
NavHost(navController = navController, startDestination = HOME) { NavHost(
navController = navController,
startDestination = HOME,
enterTransition = { enterTransition() },
exitTransition = { exitTransition() },
popEnterTransition = { popEnterTransition() },
popExitTransition = { popExitTransition() }
) {
composable(HOME) { backStackEntry -> composable(HOME) { backStackEntry ->
WrapHome( WrapHome(
onPageChange = { onPageChange = {
homeViewModel.screenIndex.value = it homeViewModel.screenIndex.value = it
}, },
goBack = { finish() }, goBack = { finish() },
goSettings = { navController.navigateJustOnce(SETTINGS) },
goScan = { navController.navigateJustOnce(SCAN) }, goScan = { navController.navigateJustOnce(SCAN) },
goSendConfirmation = { zecSend ->
navController.currentBackStackEntry?.savedStateHandle?.let { handle ->
handle[SEND_CONFIRM_RECIPIENT_ADDRESS] =
Json.encodeToString(
serializer = SerializableAddress.serializer(),
value = zecSend.destination.toSerializableAddress()
)
handle[SEND_CONFIRM_AMOUNT] = zecSend.amount.value
handle[SEND_CONFIRM_MEMO] = zecSend.memo.value
handle[SEND_CONFIRM_PROPOSAL] = zecSend.proposal?.toByteArray()
}
navController.navigateJustOnce(SEND_CONFIRMATION)
},
goSettings = { navController.navigateJustOnce(SETTINGS) },
// At this point we only read scan result data // At this point we only read scan result data
sendArgumentsWrapper = sendArgumentsWrapper =
SendArgumentsWrapper( SendArgumentsWrapper(
recipientAddress = recipientAddress =
backStackEntry.savedStateHandle.get<String>(SEND_RECIPIENT_ADDRESS)?.let { backStackEntry.savedStateHandle.get<String>(SEND_SCAN_RECIPIENT_ADDRESS)?.let {
Json.decodeFromString<ScanResult>(it).toRecipient() Json.decodeFromString<SerializableAddress>(it).toRecipient()
}, },
amount = backStackEntry.savedStateHandle.get<String>(SEND_AMOUNT), ).also {
memo = backStackEntry.savedStateHandle.get<String>(SEND_MEMO) // Remove Send screen arguments passed from the Scan screen if some exist after we use them
), backStackEntry.savedStateHandle.remove<String>(SEND_SCAN_RECIPIENT_ADDRESS)
},
) )
// Remove used Send screen parameters passed from the Scan screen if some exist
backStackEntry.savedStateHandle.remove<String>(SEND_RECIPIENT_ADDRESS)
backStackEntry.savedStateHandle.remove<String>(SEND_AMOUNT)
backStackEntry.savedStateHandle.remove<String>(SEND_MEMO)
if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) { if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) {
WrapCheckForUpdate() WrapCheckForUpdate()
@ -135,11 +165,11 @@ internal fun MainActivity.Navigation() {
composable(SCAN) { composable(SCAN) {
WrapScanValidator( WrapScanValidator(
onScanValid = { scanResult -> onScanValid = { scanResult ->
// At this point we only pass scan result data to recipient address
navController.previousBackStackEntry?.savedStateHandle?.apply { navController.previousBackStackEntry?.savedStateHandle?.apply {
set(SEND_RECIPIENT_ADDRESS, Json.encodeToString(ScanResult.serializer(), scanResult)) set(
set(SEND_AMOUNT, null) SEND_SCAN_RECIPIENT_ADDRESS,
set(SEND_MEMO, null) Json.encodeToString(SerializableAddress.serializer(), scanResult)
)
} }
navController.popBackStackJustOnce(SCAN) navController.popBackStackJustOnce(SCAN)
}, },
@ -152,6 +182,23 @@ internal fun MainActivity.Navigation() {
onConfirm = { navController.popBackStackJustOnce(EXPORT_PRIVATE_DATA) } onConfirm = { navController.popBackStackJustOnce(EXPORT_PRIVATE_DATA) }
) )
} }
composable(route = SEND_CONFIRMATION) {
navController.previousBackStackEntry?.let { backStackEntry ->
WrapSendConfirmation(
goBack = { navController.popBackStackJustOnce(SEND_CONFIRMATION) },
goHome = { navController.navigateJustOnce(HOME) },
arguments =
SendConfirmationArgsWrapper.fromSavedStateHandle(backStackEntry.savedStateHandle).also {
// Remove SendConfirmation screen arguments passed from the Send screen if some exist
// after we use them
backStackEntry.savedStateHandle.remove<String>(SEND_CONFIRM_RECIPIENT_ADDRESS)
backStackEntry.savedStateHandle.remove<Long>(SEND_CONFIRM_AMOUNT)
backStackEntry.savedStateHandle.remove<String>(SEND_CONFIRM_MEMO)
backStackEntry.savedStateHandle.remove<ByteArray>(SEND_CONFIRM_PROPOSAL)
}
)
}
}
} }
} }
@ -184,23 +231,24 @@ private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: Strin
} }
object NavigationArguments { object NavigationArguments {
const val SEND_RECIPIENT_ADDRESS = "send_recipient_address" const val SEND_SCAN_RECIPIENT_ADDRESS = "send_scan_recipient_address"
const val SEND_AMOUNT = "send_amount"
const val SEND_MEMO = "send_memo" const val SEND_CONFIRM_RECIPIENT_ADDRESS = "send_confirm_recipient_address"
const val SEND_CONFIRM_AMOUNT = "send_confirm_amount"
const val SEND_CONFIRM_MEMO = "send_confirm_memo"
const val SEND_CONFIRM_PROPOSAL = "send_confirm_proposal"
} }
object NavigationTargets { object NavigationTargets {
const val ABOUT = "about" const val ABOUT = "about"
const val ACCOUNT = "account"
const val ADVANCED_SETTINGS = "advanced_settings" const val ADVANCED_SETTINGS = "advanced_settings"
const val EXPORT_PRIVATE_DATA = "export_private_data" const val EXPORT_PRIVATE_DATA = "export_private_data"
const val HOME = "home" const val HOME = "home"
const val CHOOSE_SERVER = "choose_server" const val CHOOSE_SERVER = "choose_server"
const val RECEIVE = "receive"
const val REQUEST = "request" const val REQUEST = "request"
const val SCAN = "scan" const val SCAN = "scan"
const val SEED_RECOVERY = "seed_recovery" const val SEED_RECOVERY = "seed_recovery"
const val SEND = "send" const val SEND_CONFIRMATION = "send_confirmation"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val SUPPORT = "support" const val SUPPORT = "support"
} }

View File

@ -61,7 +61,6 @@ private fun BalanceWidgetPreview() {
} }
@Composable @Composable
@Suppress("LongMethod")
fun BalanceWidget( fun BalanceWidget(
walletSnapshot: WalletSnapshot, walletSnapshot: WalletSnapshot,
isReferenceToBalances: Boolean, isReferenceToBalances: Boolean,
@ -75,25 +74,7 @@ fun BalanceWidget(
.then(modifier), .then(modifier),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Row( BalanceWidgetBigLineOnly(text = walletSnapshot.totalBalance().toZecString())
verticalAlignment = Alignment.CenterVertically
) {
StyledBalance(
balanceString = walletSnapshot.totalBalance().toZecString(),
textStyles =
Pair(
ZcashTheme.extendedTypography.balanceWidgetStyles.first,
ZcashTheme.extendedTypography.balanceWidgetStyles.second
)
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall))
Image(
painter = painterResource(id = R.drawable.ic_zcash_zec_icon),
contentDescription = null,
)
}
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@ -131,3 +112,30 @@ fun BalanceWidget(
} }
} }
} }
@Composable
fun BalanceWidgetBigLineOnly(
text: String,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
StyledBalance(
balanceString = text,
textStyles =
Pair(
ZcashTheme.extendedTypography.balanceWidgetStyles.first,
ZcashTheme.extendedTypography.balanceWidgetStyles.second
)
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall))
Image(
painter = painterResource(id = R.drawable.ic_zcash_zec_icon),
contentDescription = null,
)
}
}

View File

@ -0,0 +1,35 @@
package co.electriccoin.zcash.ui.common.model
import cash.z.ecc.android.sdk.model.WalletAddress
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.coroutines.runBlocking
import kotlinx.serialization.Serializable
@Serializable
data class SerializableAddress(
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"
}
}
internal fun toRecipient() = RecipientAddressState(address, type)
// Calling the conversion inside the blocking coroutine is ok, as we do not expect it to be time-consuming
internal fun toWalletAddress() =
runBlocking {
when (type) {
AddressType.Unified -> WalletAddress.Unified.new(address)
AddressType.Shielded -> WalletAddress.Sapling.new(address)
AddressType.Transparent -> WalletAddress.Transparent.new(address)
is AddressType.Invalid -> error("Invalid address type")
}
}
}

View File

@ -36,6 +36,7 @@ import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
import co.electriccoin.zcash.ui.screen.account.state.TransactionHistorySyncState import co.electriccoin.zcash.ui.screen.account.state.TransactionHistorySyncState
import co.electriccoin.zcash.ui.screen.account.state.TransactionOverviewExt import co.electriccoin.zcash.ui.screen.account.state.TransactionOverviewExt
import co.electriccoin.zcash.ui.screen.account.state.getSortHeight
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -186,23 +187,32 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
synchronizer synchronizer
.filterNotNull() .filterNotNull()
.flatMapLatest { synchronizer -> .flatMapLatest { synchronizer ->
synchronizer.transactions combine(
.combine(synchronizer.status) { synchronizer.transactions,
transactions: List<TransactionOverview>, status: Synchronizer.Status -> synchronizer.status,
val enhancedTransactions = synchronizer.networkHeight.filterNotNull()
transactions.map { ) {
transactions: List<TransactionOverview>,
status: Synchronizer.Status,
networkHeight: BlockHeight ->
val enhancedTransactions =
transactions
.sortedByDescending {
it.getSortHeight(networkHeight)
}
.map {
if (it.isSentTransaction) { if (it.isSentTransaction) {
TransactionOverviewExt(it, synchronizer.getRecipients(it).firstOrNull()) TransactionOverviewExt(it, synchronizer.getRecipients(it).firstOrNull())
} else { } else {
TransactionOverviewExt(it, null) TransactionOverviewExt(it, null)
} }
} }
if (status.isSyncing()) { if (status.isSyncing()) {
TransactionHistorySyncState.Syncing(enhancedTransactions.toPersistentList()) TransactionHistorySyncState.Syncing(enhancedTransactions.toPersistentList())
} else { } else {
TransactionHistorySyncState.Done(enhancedTransactions.toPersistentList()) TransactionHistorySyncState.Done(enhancedTransactions.toPersistentList())
}
} }
}
} }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,

View File

@ -1,5 +1,6 @@
package co.electriccoin.zcash.ui.screen.account.state package co.electriccoin.zcash.ui.screen.account.state
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.TransactionOverview import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient import cash.z.ecc.android.sdk.model.TransactionRecipient
@ -7,3 +8,13 @@ data class TransactionOverviewExt(
val overview: TransactionOverview, val overview: TransactionOverview,
val recipient: TransactionRecipient? val recipient: TransactionRecipient?
) )
fun TransactionOverview.getSortHeight(networkHeight: BlockHeight): BlockHeight {
// Non-null assertion operator is necessary here as the smart cast to is impossible because `minedHeight` and
// `expiryHeight` are declared in a different module
return when {
minedHeight != null -> minedHeight!!
(expiryHeight?.value ?: 0) > 0 -> expiryHeight!!
else -> networkHeight
}
}

View File

@ -352,9 +352,8 @@ fun HistoryItem(
// * 1000 to covert to millis // * 1000 to covert to millis
@Suppress("MagicNumber") @Suppress("MagicNumber")
dateFormat.format(blockTimeEpochSeconds.times(1000)) dateFormat.format(blockTimeEpochSeconds.times(1000))
} ?: "-" } ?: ""
} ?: "-" } ?: ""
// For now, use the same label for the above missing transaction date
Text( Text(
text = dateString, text = dateString,

View File

@ -3,6 +3,7 @@ package co.electriccoin.zcash.ui.screen.advancedsettings.view
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -97,21 +98,24 @@ private fun AdvancedSettingsMainContent(
) { ) {
PrimaryButton( PrimaryButton(
onClick = onSeedRecovery, onClick = onSeedRecovery,
text = stringResource(R.string.advanced_settings_backup_wallet) text = stringResource(R.string.advanced_settings_backup_wallet),
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(dimens.spacingDefault)) Spacer(modifier = Modifier.height(dimens.spacingDefault))
PrimaryButton( PrimaryButton(
onClick = onExportPrivateData, onClick = onExportPrivateData,
text = stringResource(R.string.advanced_settings_export_private_data) text = stringResource(R.string.advanced_settings_export_private_data),
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(dimens.spacingDefault)) Spacer(modifier = Modifier.height(dimens.spacingDefault))
PrimaryButton( PrimaryButton(
onClick = onChooseServer, onClick = onChooseServer,
text = stringResource(R.string.advanced_settings_choose_server) text = stringResource(R.string.advanced_settings_choose_server),
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(dimens.spacingHuge)) Spacer(modifier = Modifier.height(dimens.spacingHuge))

View File

@ -151,7 +151,9 @@ internal fun WrapBalances(
synchronizer.createProposedTransactions( synchronizer.createProposedTransactions(
proposal = newProposal, proposal = newProposal,
usk = spendingKey usk = spendingKey
) ).collect {
Twig.info { "Printing only for now. Will be reworked. Result: $it" }
}
}.onSuccess { }.onSuccess {
Twig.debug { "Shielding transaction event" } Twig.debug { "Shielding transaction event" }
setShieldState(ShieldState.None) setShieldState(ShieldState.None)

View File

@ -78,7 +78,7 @@ import co.electriccoin.zcash.ui.screen.balances.model.WalletDisplayValues
@Preview("Balances") @Preview("Balances")
@Composable @Composable
private fun ComposablePreview() { private fun ComposableBalancesPreview() {
ZcashTheme(forceDarkMode = false) { ZcashTheme(forceDarkMode = false) {
GradientSurface { GradientSurface {
Balances( Balances(
@ -96,6 +96,26 @@ private fun ComposablePreview() {
} }
} }
@Preview("BalancesShieldFailure")
@Composable
private fun ComposableBalancesShieldFailurePreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Balances(
onSettings = {},
isFiatConversionEnabled = false,
isKeepScreenOnWhileSyncing = false,
isUpdateAvailable = false,
onShielding = {},
shieldState = ShieldState.Available,
walletSnapshot = WalletSnapshotFixture.new(),
isShowingErrorDialog = true,
setShowErrorDialog = {},
)
}
}
}
@Suppress("LongParameterList") @Suppress("LongParameterList")
@Composable @Composable
fun Balances( fun Balances(
@ -273,6 +293,7 @@ fun TransparentBalancePanel(
textStyle = ZcashTheme.extendedTypography.buttonTextSmall, textStyle = ZcashTheme.extendedTypography.buttonTextSmall,
enabled = shieldState == ShieldState.Available, enabled = shieldState == ShieldState.Available,
minHeight = ZcashTheme.dimens.buttonHeightSmall, minHeight = ZcashTheme.dimens.buttonHeightSmall,
modifier = Modifier.fillMaxWidth(),
outerPaddingValues = outerPaddingValues =
PaddingValues( PaddingValues(
horizontal = 54.dp, horizontal = 54.dp,

View File

@ -338,7 +338,10 @@ fun SaveButton(
onServerChange(selectedServer) onServerChange(selectedServer)
}, },
modifier = modifier modifier =
modifier.then(
Modifier.fillMaxWidth()
)
) )
} }

View File

@ -146,7 +146,8 @@ private fun ExportPrivateDataContent(
PrimaryButton( PrimaryButton(
onClick = onConfirm, onClick = onConfirm,
text = stringResource(R.string.export_data_confirm).uppercase(), text = stringResource(R.string.export_data_confirm).uppercase(),
enabled = checkedState.value enabled = checkedState.value,
modifier = Modifier.fillMaxWidth()
) )
Spacer(Modifier.height(ZcashTheme.dimens.spacingHuge)) Spacer(Modifier.height(ZcashTheme.dimens.spacingHuge))

View File

@ -8,6 +8,7 @@ import androidx.activity.viewModels
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import cash.z.ecc.android.sdk.model.ZecSend
import co.electriccoin.zcash.ui.MainActivity import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
@ -23,11 +24,13 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@Composable @Composable
@Suppress("LongParameterList")
internal fun MainActivity.WrapHome( internal fun MainActivity.WrapHome(
onPageChange: (HomeScreenIndex) -> Unit, onPageChange: (HomeScreenIndex) -> Unit,
goBack: () -> Unit, goBack: () -> Unit,
goSettings: () -> Unit, goSettings: () -> Unit,
goScan: () -> Unit, goScan: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit,
sendArgumentsWrapper: SendArgumentsWrapper sendArgumentsWrapper: SendArgumentsWrapper
) { ) {
WrapHome( WrapHome(
@ -35,6 +38,7 @@ internal fun MainActivity.WrapHome(
onPageChange = onPageChange, onPageChange = onPageChange,
goBack = goBack, goBack = goBack,
goScan = goScan, goScan = goScan,
goSendConfirmation = goSendConfirmation,
goSettings = goSettings, goSettings = goSettings,
sendArgumentsWrapper = sendArgumentsWrapper sendArgumentsWrapper = sendArgumentsWrapper
) )
@ -47,6 +51,7 @@ internal fun WrapHome(
goBack: () -> Unit, goBack: () -> Unit,
goSettings: () -> Unit, goSettings: () -> Unit,
goScan: () -> Unit, goScan: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit,
onPageChange: (HomeScreenIndex) -> Unit, onPageChange: (HomeScreenIndex) -> Unit,
sendArgumentsWrapper: SendArgumentsWrapper sendArgumentsWrapper: SendArgumentsWrapper
) { ) {
@ -98,6 +103,7 @@ internal fun WrapHome(
goToQrScanner = goScan, goToQrScanner = goScan,
goBack = homeGoBack, goBack = homeGoBack,
goBalances = { forceHomePageIndexFlow.tryEmit(ForcePage(HomeScreenIndex.BALANCES)) }, goBalances = { forceHomePageIndexFlow.tryEmit(ForcePage(HomeScreenIndex.BALANCES)) },
goSendConfirmation = goSendConfirmation,
goSettings = goSettings, goSettings = goSettings,
sendArgumentsWrapper = sendArgumentsWrapper sendArgumentsWrapper = sendArgumentsWrapper
) )

View File

@ -210,6 +210,7 @@ private fun NewWalletRecoveryMainContent(
start = ZcashTheme.dimens.screenHorizontalSpacingBig, start = ZcashTheme.dimens.screenHorizontalSpacingBig,
end = ZcashTheme.dimens.screenHorizontalSpacingBig end = ZcashTheme.dimens.screenHorizontalSpacingBig
) )
.fillMaxWidth()
) )
} }
} }

View File

@ -200,7 +200,8 @@ private fun OnboardingMainContent(
PrimaryButton( PrimaryButton(
onClick = onCreateWallet, onClick = onCreateWallet,
text = stringResource(R.string.onboarding_create_new_wallet) text = stringResource(R.string.onboarding_create_new_wallet),
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))

View File

@ -3,6 +3,7 @@ package co.electriccoin.zcash.ui.screen.request.view
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -161,7 +162,8 @@ private fun RequestMainContent(
onCreateAndSend(ZecRequest(myAddress, zatoshi!!, ZecRequestMessage(message))) onCreateAndSend(ZecRequest(myAddress, zatoshi!!, ZecRequestMessage(message)))
}, },
text = stringResource(id = R.string.request_create), text = stringResource(id = R.string.request_create),
enabled = null != zatoshi enabled = null != zatoshi,
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))

View File

@ -397,7 +397,8 @@ private fun RestoreSeedMainContent(
onClick = goNext, onClick = goNext,
enabled = isSeedValid, enabled = isSeedValid,
text = stringResource(id = R.string.restore_seed_button_next), text = stringResource(id = R.string.restore_seed_button_next),
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall) outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall),
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))
@ -771,7 +772,8 @@ private fun RestoreBirthdayMainContent(
onDone() onDone()
}, },
text = stringResource(R.string.restore_birthday_button_restore), text = stringResource(R.string.restore_birthday_button_restore),
enabled = isBirthdayValid enabled = isBirthdayValid,
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))

View File

@ -9,16 +9,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.MainActivity import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.SerializableAddress
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator 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.util.SettingsUtil
import co.electriccoin.zcash.ui.screen.scan.view.Scan import co.electriccoin.zcash.ui.screen.scan.view.Scan
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
internal fun MainActivity.WrapScanValidator( internal fun MainActivity.WrapScanValidator(
onScanValid: (address: ScanResult) -> Unit, onScanValid: (address: SerializableAddress) -> Unit,
goBack: () -> Unit goBack: () -> Unit
) { ) {
WrapScan( WrapScan(
@ -31,7 +31,7 @@ internal fun MainActivity.WrapScanValidator(
@Composable @Composable
fun WrapScan( fun WrapScan(
activity: ComponentActivity, activity: ComponentActivity,
onScanValid: (address: ScanResult) -> Unit, onScanValid: (address: SerializableAddress) -> Unit,
goBack: () -> Unit goBack: () -> Unit
) { ) {
val walletViewModel by activity.viewModels<WalletViewModel>() val walletViewModel by activity.viewModels<WalletViewModel>()
@ -55,7 +55,7 @@ fun WrapScan(
val addressType = synchronizer.validateAddress(result) val addressType = synchronizer.validateAddress(result)
val isAddressValid = !addressType.isNotValid val isAddressValid = !addressType.isNotValid
if (isAddressValid) { if (isAddressValid) {
onScanValid(ScanResult(result, addressType)) onScanValid(SerializableAddress(result, addressType))
} else { } else {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
message = activity.getString(R.string.scan_validation_invalid_address) message = activity.getString(R.string.scan_validation_invalid_address)

View File

@ -1,22 +0,0 @@
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

@ -138,7 +138,8 @@ private fun SecurityWarningContent(
PrimaryButton( PrimaryButton(
onClick = onConfirm, onClick = onConfirm,
text = stringResource(R.string.security_warning_confirm).uppercase(), text = stringResource(R.string.security_warning_confirm).uppercase(),
enabled = checkedState.value enabled = checkedState.value,
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))

View File

@ -220,6 +220,7 @@ private fun SeedRecoveryMainContent(
start = ZcashTheme.dimens.screenHorizontalSpacingBig, start = ZcashTheme.dimens.screenHorizontalSpacingBig,
end = ZcashTheme.dimens.screenHorizontalSpacingBig end = ZcashTheme.dimens.screenHorizontalSpacingBig
) )
.fillMaxWidth()
) )
} }
} }

View File

@ -45,6 +45,7 @@ internal fun WrapSend(
goToQrScanner: () -> Unit, goToQrScanner: () -> Unit,
goBack: () -> Unit, goBack: () -> Unit,
goBalances: () -> Unit, goBalances: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit,
goSettings: () -> Unit, goSettings: () -> Unit,
) { ) {
val hasCameraFeature = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) val hasCameraFeature = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
@ -80,12 +81,13 @@ internal fun WrapSend(
goBack, goBack,
goBalances, goBalances,
goSettings, goSettings,
goSendConfirmation,
hasCameraFeature, hasCameraFeature,
monetarySeparators monetarySeparators
) )
} }
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @Suppress("LongParameterList", "LongMethod")
@VisibleForTesting @VisibleForTesting
@Composable @Composable
internal fun WrapSend( internal fun WrapSend(
@ -98,6 +100,7 @@ internal fun WrapSend(
goBack: () -> Unit, goBack: () -> Unit,
goBalances: () -> Unit, goBalances: () -> Unit,
goSettings: () -> Unit, goSettings: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit,
hasCameraFeature: Boolean, hasCameraFeature: Boolean,
monetarySeparators: MonetarySeparators monetarySeparators: MonetarySeparators
) { ) {
@ -105,11 +108,6 @@ internal fun WrapSend(
val context = LocalContext.current 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
// 2. Show a pre-filled Send form
// 3. Go directly to the Confirmation screen
val (sendStage, setSendStage) = val (sendStage, setSendStage) =
rememberSaveable(stateSaver = SendStage.Saver) { mutableStateOf(SendStage.Form) } rememberSaveable(stateSaver = SendStage.Saver) { mutableStateOf(SendStage.Form) }
@ -140,41 +138,18 @@ internal fun WrapSend(
) )
) )
} }
if (sendArgumentsWrapper?.amount != null) {
setAmountState(
AmountState.new(
context = context,
value = sendArgumentsWrapper.amount,
monetarySeparators = monetarySeparators
)
)
}
// Memo computation: // Memo computation:
val (memoState, setMemoState) = val (memoState, setMemoState) =
rememberSaveable(stateSaver = MemoState.Saver) { rememberSaveable(stateSaver = MemoState.Saver) {
mutableStateOf(MemoState.new(zecSend?.memo?.value ?: "")) mutableStateOf(MemoState.new(zecSend?.memo?.value ?: ""))
} }
if (sendArgumentsWrapper?.memo != null) {
setMemoState(MemoState.new(sendArgumentsWrapper.memo))
}
val onBackAction = { val onBackAction = {
when (sendStage) { when (sendStage) {
SendStage.Form -> goBack() SendStage.Form -> goBack()
SendStage.Confirmation -> setSendStage(SendStage.Form) SendStage.Proposing -> { /* no action - wait until the sending is done */ }
SendStage.Sending -> { /* no action - wait until the sending is done */ }
is SendStage.SendFailure -> setSendStage(SendStage.Form) 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()
}
} }
} }
@ -191,25 +166,23 @@ internal fun WrapSend(
Send( Send(
walletSnapshot = walletSnapshot, walletSnapshot = walletSnapshot,
sendStage = sendStage, sendStage = sendStage,
onSendStageChange = setSendStage,
zecSend = zecSend, zecSend = zecSend,
onCreateZecSend = { newZecSend -> onCreateZecSend = { newZecSend ->
scope.launch { scope.launch {
Twig.debug { "Getting send transaction proposal" } Twig.debug { "Getting send transaction proposal" }
runCatching { runCatching {
synchronizer.proposeSend(spendingKey.account, newZecSend) synchronizer.proposeSend(spendingKey.account, newZecSend)
}.onSuccess { proposal ->
Twig.debug { "Transaction proposal successful: ${proposal.toPrettyString()}" }
val enrichedZecSend = newZecSend.copy(proposal = proposal)
setZecSend(enrichedZecSend)
goSendConfirmation(enrichedZecSend)
}.onFailure {
Twig.error(it) { "Transaction proposal failed" }
// TODO [#1161]: Remove Send-Success and rework Send-Failure
// TODO [#1161]: https://github.com/Electric-Coin-Company/zashi-android/issues/1161
setSendStage(SendStage.SendFailure(it.message ?: ""))
} }
.onSuccess { proposal ->
Twig.debug { "Transaction proposal successful: ${proposal.toPrettyString()}" }
setSendStage(SendStage.Confirmation)
setZecSend(newZecSend.copy(proposal = proposal))
}
.onFailure {
Twig.error(it) { "Transaction proposal failed" }
// TODO [#1161]: Remove Send-Success and rework Send-Failure
// TODO [#1161]: https://github.com/Electric-Coin-Company/zashi-android/issues/1161
setSendStage(SendStage.SendFailure(it.message ?: ""))
}
} }
}, },
focusManager = focusManager, focusManager = focusManager,
@ -228,30 +201,6 @@ internal fun WrapSend(
) )
} }
}, },
onCreateAndSend = { newZecSend ->
scope.launch {
Twig.debug { "Sending transaction" }
// TODO [#1294]: Add Send.Multiple-Trx-Failed screen
// TODO [#1294]: Note that the following processing is not entirely correct and will be reworked
// TODO [#1294]: https://github.com/Electric-Coin-Company/zashi-android/issues/1294
runCatching {
// The not-null assertion operator is necessary here even if we check its nullability before
// due to: "Smart cast to 'Proposal' is impossible, because 'zecSend.proposal' is a public API
// property declared in different module
// See more details on the Kotlin forum
checkNotNull(newZecSend.proposal)
synchronizer.createProposedTransactions(newZecSend.proposal!!, spendingKey)
}
.onSuccess {
setSendStage(SendStage.SendSuccessful)
Twig.debug { "Transaction id:$it submitted successfully" }
}
.onFailure {
Twig.error(it) { "Transaction submission failed" }
setSendStage(SendStage.SendFailure(it.message ?: ""))
}
}
},
memoState = memoState, memoState = memoState,
setMemoState = setMemoState, setMemoState = setMemoState,
amountState = amountState, amountState = amountState,

View File

@ -5,7 +5,6 @@ package co.electriccoin.zcash.ui.screen.send
*/ */
object SendTag { object SendTag {
const val SEND_FORM_BUTTON = "send_form_button" const val SEND_FORM_BUTTON = "send_form_button"
const val SEND_CONFIRMATION_BUTTON = "send_confirmation_button"
const val SEND_FAILED_BUTTON = "send_failed_button" const val SEND_FAILED_BUTTON = "send_failed_button"
const val SEND_SUCCESS_BUTTON = "send_success_button" const val SEND_SUCCESS_BUTTON = "send_success_button"
} }

View File

@ -6,7 +6,9 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import cash.z.ecc.android.sdk.model.WalletAddress import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.SerializableAddress
/** /**
* How far into the address will be abbreviation look forwards and backwards. * How far into the address will be abbreviation look forwards and backwards.
@ -26,5 +28,16 @@ internal fun WalletAddress.abbreviated(context: Context): String {
val firstFive = address.substring(0, ABBREVIATION_INDEX) val firstFive = address.substring(0, ABBREVIATION_INDEX)
val lastFive = address.substring(address.length - ABBREVIATION_INDEX, address.length) val lastFive = address.substring(address.length - ABBREVIATION_INDEX, address.length)
return context.getString(R.string.send_confirmation_abbreviated_address_format, firstFive, lastFive) return context.getString(R.string.send_abbreviated_address_format, firstFive, lastFive)
} }
internal fun WalletAddress.toSerializableAddress() =
SerializableAddress(
address = address,
type =
when (this) {
is WalletAddress.Unified -> AddressType.Unified
is WalletAddress.Sapling -> AddressType.Shielded
is WalletAddress.Transparent -> AddressType.Transparent
}
)

View File

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

View File

@ -5,20 +5,14 @@ import androidx.compose.runtime.saveable.mapSaver
sealed class SendStage { sealed class SendStage {
data object Form : SendStage() data object Form : SendStage()
data object Confirmation : SendStage() data object Proposing : SendStage()
data object Sending : SendStage()
data class SendFailure(val error: String) : SendStage() data class SendFailure(val error: String) : SendStage()
data object SendSuccessful : SendStage()
companion object { companion object {
private const val TYPE_FORM = "form" // $NON-NLS private const val TYPE_FORM = "form" // $NON-NLS
private const val TYPE_CONFIRMATION = "confirmation" // $NON-NLS private const val TYPE_PROPOSING = "proposing" // $NON-NLS
private const val TYPE_SENDING = "sending" // $NON-NLS
private const val TYPE_FAILURE = "failure" // $NON-NLS private const val TYPE_FAILURE = "failure" // $NON-NLS
private const val TYPE_SUCCESSFUL = "successful" // $NON-NLS
private const val KEY_TYPE = "type" // $NON-NLS private const val KEY_TYPE = "type" // $NON-NLS
private const val KEY_ERROR = "error" // $NON-NLS private const val KEY_ERROR = "error" // $NON-NLS
@ -34,10 +28,8 @@ sealed class SendStage {
val sendStageString = (it[KEY_TYPE] as String) val sendStageString = (it[KEY_TYPE] as String)
when (sendStageString) { when (sendStageString) {
TYPE_FORM -> Form TYPE_FORM -> Form
TYPE_CONFIRMATION -> Confirmation TYPE_PROPOSING -> Proposing
TYPE_SENDING -> Sending
TYPE_FAILURE -> SendFailure((it[KEY_ERROR] as String)) TYPE_FAILURE -> SendFailure((it[KEY_ERROR] as String))
TYPE_SUCCESSFUL -> SendSuccessful
else -> null else -> null
} }
} }
@ -49,13 +41,11 @@ sealed class SendStage {
val saverMap = HashMap<String, String>() val saverMap = HashMap<String, String>()
when (this) { when (this) {
Form -> saverMap[KEY_TYPE] = TYPE_FORM Form -> saverMap[KEY_TYPE] = TYPE_FORM
Confirmation -> saverMap[KEY_TYPE] = TYPE_CONFIRMATION Proposing -> saverMap[KEY_TYPE] = TYPE_PROPOSING
is SendFailure -> { is SendFailure -> {
saverMap[KEY_TYPE] = TYPE_FAILURE saverMap[KEY_TYPE] = TYPE_FAILURE
saverMap[KEY_ERROR] = this.error saverMap[KEY_ERROR] = this.error
} }
SendSuccessful -> saverMap[KEY_TYPE] = TYPE_SUCCESSFUL
Sending -> saverMap[KEY_TYPE] = TYPE_SENDING
} }
return saverMap return saverMap

View File

@ -5,7 +5,6 @@ package co.electriccoin.zcash.ui.screen.send.view
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@ -40,7 +39,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.model.Memo import cash.z.ecc.android.sdk.model.Memo
import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
@ -48,7 +46,6 @@ import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.ZecSendExt import cash.z.ecc.android.sdk.model.ZecSendExt
import cash.z.ecc.android.sdk.model.toZecString import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.android.sdk.type.AddressType 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.fixture.ZatoshiFixture
import cash.z.ecc.sdk.type.ZcashCurrency import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
@ -58,23 +55,21 @@ import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.spendableBalance import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.test.CommonTag import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.AppAlertDialog
import co.electriccoin.zcash.ui.design.component.Body import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.BodySmall import co.electriccoin.zcash.ui.design.component.BodySmall
import co.electriccoin.zcash.ui.design.component.FormTextField import co.electriccoin.zcash.ui.design.component.FormTextField
import co.electriccoin.zcash.ui.design.component.GradientSurface 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.PrimaryButton
import co.electriccoin.zcash.ui.design.component.Small
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.send.SendTag import co.electriccoin.zcash.ui.screen.send.SendTag
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.AmountState 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.MemoState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState 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.model.SendStage
import kotlinx.coroutines.runBlocking
import java.util.Locale import java.util.Locale
@Composable @Composable
@ -85,13 +80,11 @@ private fun PreviewSendForm() {
Send( Send(
walletSnapshot = WalletSnapshotFixture.new(), walletSnapshot = WalletSnapshotFixture.new(),
sendStage = SendStage.Form, sendStage = SendStage.Form,
onSendStageChange = {},
zecSend = null, zecSend = null,
onCreateZecSend = {}, onCreateZecSend = {},
focusManager = LocalFocusManager.current, focusManager = LocalFocusManager.current,
onBack = {}, onBack = {},
onSettings = {}, onSettings = {},
onCreateAndSend = {},
onQrScannerOpen = {}, onQrScannerOpen = {},
goBalances = {}, goBalances = {},
hasCameraFeature = true, hasCameraFeature = true,
@ -106,38 +99,12 @@ private fun PreviewSendForm() {
} }
} }
@Composable
@Preview("SendSuccessful")
private fun PreviewSendSuccessful() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
SendSuccessful(
zecSend =
ZecSend(
destination = runBlocking { WalletAddressFixture.sapling() },
amount = ZatoshiFixture.new(),
memo = MemoFixture.new(),
proposal = null,
),
onDone = {}
)
}
}
}
@Composable @Composable
@Preview("SendFailure") @Preview("SendFailure")
private fun PreviewSendFailure() { private fun PreviewSendFailure() {
ZcashTheme(forceDarkMode = false) { ZcashTheme(forceDarkMode = false) {
GradientSurface { GradientSurface {
SendFailure( SendFailure(
zecSend =
ZecSend(
destination = runBlocking { WalletAddressFixture.sapling() },
amount = ZatoshiFixture.new(),
memo = MemoFixture.new(),
proposal = null,
),
onDone = {}, onDone = {},
reason = "Insufficient balance" reason = "Insufficient balance"
) )
@ -145,37 +112,19 @@ private fun PreviewSendFailure() {
} }
} }
@Composable // TODO [#1260]: Cover Send screens UI with tests
@Preview("SendConfirmation") // TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
private fun PreviewSendConfirmation() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
SendConfirmation(
zecSend =
ZecSend(
destination = runBlocking { WalletAddressFixture.sapling() },
amount = ZatoshiFixture.new(),
memo = MemoFixture.new(),
proposal = null,
),
onConfirmation = {}
)
}
}
}
@Suppress("LongParameterList") @Suppress("LongParameterList")
@Composable @Composable
fun Send( fun Send(
walletSnapshot: WalletSnapshot, walletSnapshot: WalletSnapshot,
sendStage: SendStage, sendStage: SendStage,
onSendStageChange: (SendStage) -> Unit,
zecSend: ZecSend?, zecSend: ZecSend?,
onCreateZecSend: (ZecSend) -> Unit, onCreateZecSend: (ZecSend) -> Unit,
focusManager: FocusManager, focusManager: FocusManager,
onBack: () -> Unit, onBack: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onCreateAndSend: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit, onQrScannerOpen: () -> Unit,
goBalances: () -> Unit, goBalances: () -> Unit,
hasCameraFeature: Boolean, hasCameraFeature: Boolean,
@ -187,18 +136,13 @@ fun Send(
memoState: MemoState, memoState: MemoState,
) { ) {
Scaffold(topBar = { Scaffold(topBar = {
SendTopAppBar( SendTopAppBar(onSettings = onSettings)
onBack = onBack,
onSettings = onSettings,
showBackNavigationButton = (sendStage != SendStage.Sending && sendStage != SendStage.Form)
)
}) { paddingValues -> }) { paddingValues ->
SendMainContent( SendMainContent(
walletSnapshot = walletSnapshot, walletSnapshot = walletSnapshot,
onBack = onBack, onBack = onBack,
focusManager = focusManager, focusManager = focusManager,
sendStage = sendStage, sendStage = sendStage,
onSendStageChange = onSendStageChange,
zecSend = zecSend, zecSend = zecSend,
onCreateZecSend = onCreateZecSend, onCreateZecSend = onCreateZecSend,
recipientAddressState = recipientAddressState, recipientAddressState = recipientAddressState,
@ -207,7 +151,6 @@ fun Send(
setAmountState = setAmountState, setAmountState = setAmountState,
memoState = memoState, memoState = memoState,
setMemoState = setMemoState, setMemoState = setMemoState,
onSendSubmit = onCreateAndSend,
onQrScannerOpen = onQrScannerOpen, onQrScannerOpen = onQrScannerOpen,
goBalances = goBalances, goBalances = goBalances,
hasCameraFeature = hasCameraFeature, hasCameraFeature = hasCameraFeature,
@ -224,21 +167,9 @@ fun Send(
} }
@Composable @Composable
private fun SendTopAppBar( private fun SendTopAppBar(onSettings: () -> Unit) {
onBack: () -> Unit,
onSettings: () -> Unit,
showBackNavigationButton: Boolean = true
) {
SmallTopAppBar( SmallTopAppBar(
titleText = stringResource(id = R.string.send_title), titleText = stringResource(id = R.string.send_stage_send_title),
onBack = onBack,
backText =
if (showBackNavigationButton) {
stringResource(id = R.string.send_back)
} else {
null
},
backContentDescriptionText = stringResource(id = R.string.send_back_content_description),
hamburgerMenuActions = { hamburgerMenuActions = {
IconButton( IconButton(
onClick = onSettings, onClick = onSettings,
@ -263,8 +194,6 @@ private fun SendMainContent(
zecSend: ZecSend?, zecSend: ZecSend?,
onCreateZecSend: (ZecSend) -> Unit, onCreateZecSend: (ZecSend) -> Unit,
sendStage: SendStage, sendStage: SendStage,
onSendStageChange: (SendStage) -> Unit,
onSendSubmit: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit, onQrScannerOpen: () -> Unit,
recipientAddressState: RecipientAddressState, recipientAddressState: RecipientAddressState,
onRecipientAddressChange: (String) -> Unit, onRecipientAddressChange: (String) -> Unit,
@ -276,7 +205,9 @@ private fun SendMainContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when { when {
(sendStage == SendStage.Form || null == zecSend) -> { // For now, we merge [SendStage.Form] and [SendStage.Proposing] into one stage. We could eventually display a
// loader if calling the Proposal API takes longer than expected
(sendStage == SendStage.Form || sendStage == SendStage.Proposing || null == zecSend) -> {
SendForm( SendForm(
walletSnapshot = walletSnapshot, walletSnapshot = walletSnapshot,
recipientAddressState = recipientAddressState, recipientAddressState = recipientAddressState,
@ -293,40 +224,17 @@ private fun SendMainContent(
modifier = modifier modifier = modifier
) )
} }
(sendStage == SendStage.Confirmation) -> {
SendConfirmation(
zecSend = zecSend,
onConfirmation = {
onSendStageChange(SendStage.Sending)
onSendSubmit(zecSend)
},
modifier = modifier
)
}
(sendStage == SendStage.Sending) -> {
Sending(
zecSend = zecSend,
modifier = modifier
)
}
(sendStage == SendStage.SendSuccessful) -> {
SendSuccessful(
zecSend = zecSend,
onDone = onBack,
modifier = modifier,
)
}
(sendStage is SendStage.SendFailure) -> { (sendStage is SendStage.SendFailure) -> {
SendFailure( SendFailure(
zecSend = zecSend,
reason = sendStage.error, reason = sendStage.error,
onDone = onBack, onDone = onBack,
modifier = modifier,
) )
} }
} }
} }
const val DEFAULT_LESS_THAN_FEE = 100_000L
// TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button. // TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button.
// TODO [#217]: https://github.com/Electric-Coin-Company/zashi-android/issues/217 // TODO [#217]: https://github.com/Electric-Coin-Company/zashi-android/issues/217
@ -471,7 +379,10 @@ private fun SendForm(
}, },
text = stringResource(id = R.string.send_create), text = stringResource(id = R.string.send_create),
enabled = sendButtonEnabled, enabled = sendButtonEnabled,
modifier = Modifier.testTag(SendTag.SEND_FORM_BUTTON) modifier =
Modifier
.testTag(SendTag.SEND_FORM_BUTTON)
.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
@ -482,8 +393,7 @@ private fun SendForm(
id = R.string.send_fee, id = R.string.send_fee,
// TODO [#1047]: Representing Zatoshi amount // TODO [#1047]: Representing Zatoshi amount
// TODO [#1047]: https://github.com/Electric-Coin-Company/zashi-android/issues/1047 // TODO [#1047]: https://github.com/Electric-Coin-Company/zashi-android/issues/1047
@Suppress("MagicNumber") Zatoshi(DEFAULT_LESS_THAN_FEE).toZecString()
Zatoshi(100_000L).toZecString()
), ),
textFontWeight = FontWeight.SemiBold textFontWeight = FontWeight.SemiBold
) )
@ -511,7 +421,7 @@ fun SendFormAddressTextField(
// Scroll TextField above ime keyboard // Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester) .bringIntoViewRequester(bringIntoViewRequester)
) { ) {
Body(text = stringResource(id = R.string.send_address_label)) Small(text = stringResource(id = R.string.send_address_label))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
@ -617,7 +527,7 @@ fun SendFormAmountTextField(
// Scroll TextField above ime keyboard // Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester) .bringIntoViewRequester(bringIntoViewRequester)
) { ) {
Body(text = stringResource(id = R.string.send_amount_label)) Small(text = stringResource(id = R.string.send_amount_label))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
@ -682,7 +592,7 @@ fun SendFormMemoTextField(
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
painter = painterResource(id = R.drawable.send_papre_plane), painter = painterResource(id = R.drawable.send_paper_plane),
contentDescription = null, contentDescription = null,
tint = tint =
if (isMemoFieldAvailable) { if (isMemoFieldAvailable) {
@ -694,7 +604,7 @@ fun SendFormMemoTextField(
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall))
Body( Small(
text = stringResource(id = R.string.send_memo_label), text = stringResource(id = R.string.send_memo_label),
color = color =
if (isMemoFieldAvailable) { if (isMemoFieldAvailable) {
@ -709,7 +619,12 @@ fun SendFormMemoTextField(
FormTextField( FormTextField(
enabled = isMemoFieldAvailable, enabled = isMemoFieldAvailable,
value = memoState.text, value =
if (isMemoFieldAvailable) {
memoState.text
} else {
""
},
onValueChange = { onValueChange = {
setMemoState(MemoState.new(it)) setMemoState(MemoState.new(it))
}, },
@ -762,238 +677,17 @@ fun SendFormMemoTextField(
} }
@Composable @Composable
private fun SendConfirmation( @Suppress("UNUSED_PARAMETER")
zecSend: ZecSend,
onConfirmation: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Body(
stringResource(
R.string.send_confirmation_amount_format,
zecSend.amount.toZecString(),
)
)
Body(
stringResource(
R.string.send_confirmation_address_format,
zecSend.destination.abbreviated()
)
)
if (zecSend.memo.value.isNotEmpty()) {
Body(
stringResource(
R.string.send_confirmation_memo_format,
zecSend.memo.value
)
)
}
if (zecSend.proposal != null) {
// The not-null assertion operator is necessary here even if we check its nullability before
// due to: "Smart cast to 'Proposal' is impossible, because 'zecSend.proposal' is a public API
// property declared in different module
// See more details on the Kotlin forum
checkNotNull(zecSend.proposal)
Body(
stringResource(
R.string.send_confirmation_fee_format,
zecSend.proposal!!.totalFeeRequired().toZecString()
)
)
}
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
PrimaryButton(
modifier =
Modifier
.padding(top = ZcashTheme.dimens.spacingSmall)
.testTag(SendTag.SEND_CONFIRMATION_BUTTON),
onClick = onConfirmation,
text = stringResource(id = R.string.send_confirmation_button),
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
}
@Composable
private fun Sending(
zecSend: ZecSend,
modifier: Modifier = Modifier
) {
Column(modifier) {
Header(
text =
stringResource(
R.string.send_in_progress_amount_format,
zecSend.amount.toZecString()
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Body(
text = zecSend.destination.abbreviated(),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
if (zecSend.memo.value.isNotEmpty()) {
Body(
stringResource(
R.string.send_in_progress_memo_format,
zecSend.memo.value
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
Body(
modifier =
Modifier
.padding(vertical = ZcashTheme.dimens.spacingSmall)
.fillMaxWidth(),
text = stringResource(R.string.send_in_progress_wait),
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun SendSuccessful(
zecSend: ZecSend,
onDone: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Header(
text = stringResource(R.string.send_successful_title),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(
modifier =
Modifier
.fillMaxWidth()
.height(ZcashTheme.dimens.spacingDefault)
)
Body(
stringResource(
R.string.send_successful_amount_address_memo,
zecSend.amount.toZecString(),
zecSend.destination.abbreviated(),
zecSend.memo.valueOrEmptyChar()
)
)
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
PrimaryButton(
modifier =
Modifier
.padding(top = ZcashTheme.dimens.spacingSmall)
.testTag(SendTag.SEND_SUCCESS_BUTTON),
text = stringResource(R.string.send_successful_button),
onClick = onDone,
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
}
@Composable
private fun SendFailure( private fun SendFailure(
zecSend: ZecSend,
onDone: () -> Unit, onDone: () -> Unit,
reason: String, reason: String,
modifier: Modifier = Modifier,
) { ) {
Column( // Once we ensure that the [reason] contains a localized message, we can leverage it for the UI prompt
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Header(
text = stringResource(R.string.send_failure_title),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer( AppAlertDialog(
modifier = title = stringResource(id = R.string.send_dialog_error_title),
Modifier text = stringResource(id = R.string.send_dialog_error_text),
.fillMaxWidth() confirmButtonText = stringResource(id = R.string.send_dialog_error_btn),
.height(ZcashTheme.dimens.spacingDefault) onConfirmButtonClick = onDone
) )
Body(
stringResource(
R.string.send_failure_amount_address_memo,
zecSend.amount.toZecString(),
zecSend.destination.abbreviated(),
zecSend.memo.valueOrEmptyChar()
)
)
Spacer(
modifier =
Modifier
.fillMaxWidth()
.height(ZcashTheme.dimens.spacingDefault)
)
Body(
stringResource(
R.string.send_failure_reason,
reason,
)
)
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
PrimaryButton(
modifier =
Modifier
.padding(top = ZcashTheme.dimens.spacingSmall)
.testTag(SendTag.SEND_FAILED_BUTTON),
text = stringResource(R.string.send_failure_button),
onClick = onDone,
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
} }

View File

@ -0,0 +1,126 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.sendconfirmation
import androidx.activity.compose.BackHandler
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.ZecSend
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.send.ext.Saver
import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SendConfirmationArgsWrapper
import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SendConfirmationStage
import co.electriccoin.zcash.ui.screen.sendconfirmation.view.SendConfirmation
import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapSendConfirmation(
goBack: () -> Unit,
goHome: () -> Unit,
arguments: SendConfirmationArgsWrapper
) {
val walletViewModel by this.viewModels<WalletViewModel>()
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val spendingKey = walletViewModel.spendingKey.collectAsStateWithLifecycle().value
WrapSendConfirmation(
arguments,
synchronizer,
spendingKey,
goBack,
goHome,
)
}
@VisibleForTesting
@Composable
internal fun WrapSendConfirmation(
arguments: SendConfirmationArgsWrapper,
synchronizer: Synchronizer?,
spendingKey: UnifiedSpendingKey?,
goBack: () -> Unit,
goHome: () -> Unit,
) {
val scope = rememberCoroutineScope()
val zecSend by rememberSaveable(stateSaver = ZecSend.Saver) { mutableStateOf(arguments.toZecSend()) }
// Because of the [zecSend] has the same Saver as on the Send screen, we do not expect this to be ever null
checkNotNull(zecSend)
val (stage, setStage) =
rememberSaveable(stateSaver = SendConfirmationStage.Saver) {
mutableStateOf(SendConfirmationStage.Confirmation)
}
val onBackAction = {
when (stage) {
SendConfirmationStage.Confirmation -> goBack()
SendConfirmationStage.Sending -> { /* no action - wait until the sending is done */ }
is SendConfirmationStage.Failure -> setStage(SendConfirmationStage.Confirmation)
is SendConfirmationStage.MultipleTrxFailure -> {
// TODO [#1161]: Remove Send-Success and rework Send-Failure
// TODO [#1161]: https://github.com/Electric-Coin-Company/zashi-android/issues/1161
setStage(SendConfirmationStage.Confirmation)
}
}
}
BackHandler {
onBackAction()
}
if (null == synchronizer || null == spendingKey) {
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
// TODO [#1146]: https://github.com/Electric-Coin-Company/zashi-android/issues/1146
CircularScreenProgressIndicator()
} else {
SendConfirmation(
stage = stage,
onStageChange = setStage,
zecSend = zecSend!!,
onBack = onBackAction,
onCreateAndSend = { newZecSend ->
scope.launch {
Twig.debug { "Sending transactions" }
// TODO [#1294]: Add Send.Multiple-Trx-Failed screen
// TODO [#1294]: Note that the following processing is not entirely correct and will be reworked
// TODO [#1294]: https://github.com/Electric-Coin-Company/zashi-android/issues/1294
runCatching {
// The not-null assertion operator is necessary here even if we check its nullability before
// due to: "Smart cast to 'Proposal' is impossible, because 'zecSend.proposal' is a public API
// property declared in different module
// See more details on the Kotlin forum
checkNotNull(newZecSend.proposal)
synchronizer.createProposedTransactions(newZecSend.proposal!!, spendingKey).collect {
Twig.info { "Printing only for now. Will be reworked. Result: $it" }
}
}
.onSuccess {
Twig.debug { "Transaction submitted successfully" }
setStage(SendConfirmationStage.Confirmation)
goHome()
}
.onFailure {
Twig.error(it) { "Transaction submission failed" }
setStage(SendConfirmationStage.Failure(it.message ?: ""))
}
}
}
)
}
}

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.ui.screen.sendconfirmation
/**
* These are only used for automated testing.
*/
object SendConfirmationTag {
const val SEND_CONFIRMATION_SEND_BUTTON = "send_confirmation_send_button"
const val SEND_CONFIRMATION_BACK_BUTTON = "send_confirmation_back_button"
}

View File

@ -0,0 +1,65 @@
package co.electriccoin.zcash.ui.screen.sendconfirmation.model
import androidx.lifecycle.SavedStateHandle
import cash.z.ecc.android.sdk.model.Memo
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZecSend
import co.electriccoin.zcash.ui.NavigationArguments
import co.electriccoin.zcash.ui.common.model.SerializableAddress
import kotlinx.serialization.json.Json
data class SendConfirmationArgsWrapper(
val address: SerializableAddress?,
val amount: Long?,
val memo: String?,
val proposal: ByteArray?,
) {
companion object {
internal fun fromSavedStateHandle(savedStateHandle: SavedStateHandle) =
SendConfirmationArgsWrapper(
address =
savedStateHandle.get<String>(NavigationArguments.SEND_CONFIRM_RECIPIENT_ADDRESS)?.let {
Json.decodeFromString<SerializableAddress>(it)
},
amount = savedStateHandle.get<Long>(NavigationArguments.SEND_CONFIRM_AMOUNT),
memo = savedStateHandle.get<String>(NavigationArguments.SEND_CONFIRM_MEMO),
proposal = savedStateHandle.get<ByteArray>(NavigationArguments.SEND_CONFIRM_PROPOSAL),
)
}
internal fun toZecSend() =
ZecSend(
destination = address?.toWalletAddress() ?: error("Address null"),
amount = amount?.let { Zatoshi(amount) } ?: error("Amount null"),
memo = memo?.let { Memo(memo) } ?: error("Memo null"),
proposal = proposal?.let { Proposal.fromByteArray(proposal) } ?: error("Proposal null"),
)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SendConfirmationArgsWrapper
if (amount != other.amount) return false
if (memo != other.memo) return false
if (address != other.address) return false
if (proposal != null) {
if (other.proposal == null) return false
if (!proposal.contentEquals(other.proposal)) return false
} else if (other.proposal != null) {
return false
}
return true
}
override fun hashCode(): Int {
var result = amount?.hashCode() ?: 0
result = 31 * result + (memo?.hashCode() ?: 0)
result = 31 * result + (address?.hashCode() ?: 0)
result = 31 * result + (proposal?.contentHashCode() ?: 0)
return result
}
}

View File

@ -0,0 +1,62 @@
package co.electriccoin.zcash.ui.screen.sendconfirmation.model
import androidx.compose.runtime.saveable.mapSaver
sealed class SendConfirmationStage {
data object Confirmation : SendConfirmationStage()
data object Sending : SendConfirmationStage()
data class Failure(val error: String) : SendConfirmationStage()
data class MultipleTrxFailure(val error: String) : SendConfirmationStage()
companion object {
private const val TYPE_CONFIRMATION = "confirmation" // $NON-NLS
private const val TYPE_SENDING = "sending" // $NON-NLS
private const val TYPE_FAILURE = "failure" // $NON-NLS
private const val TYPE_MULTIPLE_TRX_FAILURE = "multiple_trx_failure" // $NON-NLS
private const val KEY_TYPE = "type" // $NON-NLS
private const val KEY_ERROR = "error" // $NON-NLS
internal val Saver
get() =
run {
mapSaver<SendConfirmationStage>(
save = { it.toSaverMap() },
restore = {
if (it.isEmpty()) {
null
} else {
val sendStageString = (it[KEY_TYPE] as String)
when (sendStageString) {
TYPE_CONFIRMATION -> Confirmation
TYPE_SENDING -> Sending
TYPE_FAILURE -> Failure((it[KEY_ERROR] as String))
TYPE_MULTIPLE_TRX_FAILURE -> MultipleTrxFailure((it[KEY_ERROR] as String))
else -> null
}
}
}
)
}
private fun SendConfirmationStage.toSaverMap(): HashMap<String, String> {
val saverMap = HashMap<String, String>()
when (this) {
Confirmation -> saverMap[KEY_TYPE] = TYPE_CONFIRMATION
Sending -> saverMap[KEY_TYPE] = TYPE_SENDING
is Failure -> {
saverMap[KEY_TYPE] = TYPE_FAILURE
saverMap[KEY_ERROR] = this.error
}
is MultipleTrxFailure -> {
saverMap[KEY_TYPE] = TYPE_FAILURE
saverMap[KEY_ERROR] = this.error
}
}
return saverMap
}
}
}

View File

@ -0,0 +1,319 @@
package co.electriccoin.zcash.ui.screen.sendconfirmation.view
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.BalanceWidgetBigLineOnly
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.AppAlertDialog
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.Small
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.StyledBalance
import co.electriccoin.zcash.ui.design.component.Tiny
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.sendconfirmation.SendConfirmationTag
import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SendConfirmationStage
import kotlinx.coroutines.runBlocking
@Composable
@Preview("SendConfirmationFailure")
private fun PreviewSendConfirmationFailure() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
SendFailure(
onDone = {},
reason = "Failed due to network error"
)
}
}
}
@Composable
@Preview("SendConfirmation")
private fun PreviewSendConfirmation() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
SendConfirmationContent(
zecSend =
ZecSend(
destination = runBlocking { WalletAddressFixture.sapling() },
amount = ZatoshiFixture.new(),
memo = MemoFixture.new(),
proposal = null,
),
onConfirmation = {},
onBack = {},
isSending = false
)
}
}
}
// TODO [#1260]: Cover Send screens UI with tests
// TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
@Composable
fun SendConfirmation(
stage: SendConfirmationStage,
onStageChange: (SendConfirmationStage) -> Unit,
zecSend: ZecSend,
onBack: () -> Unit,
onCreateAndSend: (ZecSend) -> Unit,
) {
Scaffold(topBar = {
SendConfirmationTopAppBar()
}) { paddingValues ->
SendConfirmationMainContent(
onBack = onBack,
stage = stage,
onStageChange = onStageChange,
zecSend = zecSend,
onSendSubmit = onCreateAndSend,
modifier =
Modifier
.padding(
top = paddingValues.calculateTopPadding() + ZcashTheme.dimens.spacingDefault,
bottom = paddingValues.calculateBottomPadding(),
start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
end = ZcashTheme.dimens.screenHorizontalSpacingRegular
)
)
}
}
@Composable
private fun SendConfirmationTopAppBar() {
SmallTopAppBar(
titleText = stringResource(id = R.string.send_stage_confirmation_title)
)
}
@Composable
@Suppress("LongParameterList")
private fun SendConfirmationMainContent(
onBack: () -> Unit,
zecSend: ZecSend,
stage: SendConfirmationStage,
onStageChange: (SendConfirmationStage) -> Unit,
onSendSubmit: (ZecSend) -> Unit,
modifier: Modifier = Modifier,
) {
when (stage) {
SendConfirmationStage.Confirmation -> {
SendConfirmationContent(
zecSend = zecSend,
onBack = onBack,
onConfirmation = {
onStageChange(SendConfirmationStage.Sending)
onSendSubmit(zecSend)
},
isSending = false,
modifier = modifier
)
}
SendConfirmationStage.Sending -> {
SendConfirmationContent(
zecSend = zecSend,
onBack = onBack,
onConfirmation = {},
isSending = true,
modifier = modifier
)
}
is SendConfirmationStage.Failure -> {
SendFailure(
onDone = onBack,
reason = stage.error,
)
}
is SendConfirmationStage.MultipleTrxFailure -> {
// TODO [#1161]: Remove Send-Success and rework Send-Failure
// TODO [#1161]: https://github.com/Electric-Coin-Company/zashi-android/issues/1161
SendFailure(
onDone = onBack,
reason = stage.error,
)
}
}
}
const val DEFAULT_LESS_THAN_FEE = 100_000L
@Composable
@Suppress("LongMethod")
private fun SendConfirmationContent(
zecSend: ZecSend,
onConfirmation: () -> Unit,
onBack: () -> Unit,
isSending: Boolean,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.Start,
modifier =
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
Small(stringResource(R.string.send_confirmation_amount))
BalanceWidgetBigLineOnly(text = zecSend.amount.toZecString())
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Small(stringResource(R.string.send_confirmation_address))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Tiny(zecSend.destination.address)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingXlarge))
Small(stringResource(R.string.send_confirmation_fee))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
StyledBalance(
balanceString =
if (zecSend.proposal == null) {
Zatoshi(DEFAULT_LESS_THAN_FEE).toZecString()
} else {
// The not-null assertion operator is necessary here even if we check its nullability before
// due to: "Smart cast to 'Proposal' is impossible, because 'zecSend.proposal' is a public API
// property declared in different module
// See more details on the Kotlin forum
checkNotNull(zecSend.proposal)
zecSend.proposal!!.totalFeeRequired().toZecString()
},
textStyles =
Pair(
ZcashTheme.extendedTypography.balanceSingleStyles.first,
ZcashTheme.extendedTypography.balanceSingleStyles.second
)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingXlarge))
if (zecSend.memo.value.isNotEmpty()) {
Small(stringResource(R.string.send_confirmation_memo))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Box(
modifier =
Modifier
.fillMaxWidth()
.border(width = 1.dp, color = ZcashTheme.colors.textFieldFrame)
) {
Tiny(
text = zecSend.memo.value,
modifier =
Modifier
.fillMaxWidth()
.padding(all = ZcashTheme.dimens.spacingMid)
)
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingXlarge))
}
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
SendConfirmationActionButtons(
isSending = isSending,
onBack = onBack,
onConfirmation = onConfirmation
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))
}
}
const val BUTTON_WIDTH_RATIO = 0.5f
@Composable
fun SendConfirmationActionButtons(
onConfirmation: () -> Unit,
onBack: () -> Unit,
isSending: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
) {
PrimaryButton(
text = stringResource(id = R.string.send_confirmation_send_button),
onClick = onConfirmation,
enabled = !isSending,
showProgressBar = isSending,
minHeight = ZcashTheme.dimens.buttonHeightSmall,
modifier =
Modifier
.testTag(SendConfirmationTag.SEND_CONFIRMATION_SEND_BUTTON)
.weight(BUTTON_WIDTH_RATIO)
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingLarge))
PrimaryButton(
text = stringResource(R.string.send_confirmation_back_button),
onClick = onBack,
enabled = !isSending,
minHeight = ZcashTheme.dimens.buttonHeightSmall,
modifier =
Modifier
.testTag(SendConfirmationTag.SEND_CONFIRMATION_BACK_BUTTON)
.weight(BUTTON_WIDTH_RATIO)
)
}
}
@Composable
@Suppress("UNUSED_PARAMETER")
private fun SendFailure(
onDone: () -> Unit,
reason: String,
) {
// Once we ensure that the [reason] contains a localized message, we can leverage it for the UI prompt
AppAlertDialog(
title = stringResource(id = R.string.send_confirmation_dialog_error_title),
text = stringResource(id = R.string.send_confirmation_dialog_error_text),
confirmButtonText = stringResource(id = R.string.send_confirmation_dialog_error_btn),
onConfirmButtonClick = onDone
)
}

View File

@ -234,14 +234,16 @@ private fun SettingsMainContent(
) { ) {
PrimaryButton( PrimaryButton(
onClick = onFeedback, onClick = onFeedback,
text = stringResource(R.string.settings_send_us_feedback) text = stringResource(R.string.settings_send_us_feedback),
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(dimens.spacingDefault)) Spacer(modifier = Modifier.height(dimens.spacingDefault))
PrimaryButton( PrimaryButton(
onClick = onAdvancedSettings, onClick = onAdvancedSettings,
text = stringResource(R.string.settings_advanced_settings) text = stringResource(R.string.settings_advanced_settings),
modifier = Modifier.fillMaxWidth()
) )
Spacer( Spacer(
@ -255,7 +257,8 @@ private fun SettingsMainContent(
PrimaryButton( PrimaryButton(
onClick = onAbout, onClick = onAbout,
text = stringResource(R.string.settings_about) text = stringResource(R.string.settings_about),
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(dimens.spacingHuge)) Spacer(modifier = Modifier.height(dimens.spacingHuge))

View File

@ -165,7 +165,8 @@ private fun SupportMainContent(
PrimaryButton( PrimaryButton(
onClick = { setShowDialog(true) }, onClick = { setShowDialog(true) },
text = stringResource(id = R.string.support_send) text = stringResource(id = R.string.support_send),
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))

View File

@ -149,9 +149,12 @@ private fun UpdateBottomAppBar(
PrimaryButton( PrimaryButton(
onClick = { onDownload(UpdateState.Running) }, onClick = { onDownload(UpdateState.Running) },
text = stringResource(R.string.update_download_button), text = stringResource(R.string.update_download_button),
modifier = Modifier.testTag(UpdateTag.BTN_DOWNLOAD), modifier =
Modifier
.testTag(UpdateTag.BTN_DOWNLOAD)
.fillMaxWidth(),
enabled = updateInfo.state != UpdateState.Running, enabled = updateInfo.state != UpdateState.Running,
outerPaddingValues = PaddingValues(all = ZcashTheme.dimens.spacingNone) outerPaddingValues = PaddingValues(all = ZcashTheme.dimens.spacingNone),
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))

View File

@ -1,5 +1,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="send_title">Send</string> <string name="send_stage_send_title">Send</string>
<string name="send_back">Back</string> <string name="send_back">Back</string>
<string name="send_back_content_description">Back</string> <string name="send_back_content_description">Back</string>
<string name="send_scan_content_description">Scan</string> <string name="send_scan_content_description">Scan</string>
@ -19,26 +19,10 @@
<string name="send_create">Review</string> <string name="send_create">Review</string>
<string name="send_fee">(Typical Fee &lt; <xliff:g id="fee_amount" example="0.001">%1$s</xliff:g>)</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_format" formatted="true">Send: <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC</string> <string name="send_dialog_error_title">Failed to send funds</string>
<string name="send_confirmation_address_format" formatted="true">To: <xliff:g id="address" example="zs1g7cqw … mvyzgm">%1$s</xliff:g>?</string> <string name="send_dialog_error_text">Error: The attempt to send funds failed. Try it again, please.</string>
<string name="send_confirmation_memo_format" formatted="true">Memo: <xliff:g id="memo" example="for Veronika">%1$s</xliff:g></string> <string name="send_dialog_error_btn">OK</string>
<string name="send_confirmation_fee_format" formatted="true">Fee: <xliff:g id="fee" example="0.0001">%1$s</xliff:g></string>
<string name="send_confirmation_abbreviated_address_format" formatted="true"><xliff:g id="first_five" example="zs1g7">%1$s</xliff:g><xliff:g id="last_five" example="mvyzg">%2$s</xliff:g></string>
<string name="send_confirmation_button">Press to send ZEC</string>
<string name="send_in_progress_amount_format" formatted="true">Sending <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC to</string> <string name="send_abbreviated_address_format" formatted="true"><xliff:g id="first_five" example="zs1g7">%1$s</xliff:g><xliff:g id="last_five" example="mvyzg">%2$s</xliff:g></string>
<string name="send_in_progress_memo_format" formatted="true">with a memo: <xliff:g id="memo" example="for Veronika">%1$s</xliff:g></string>
<string name="send_in_progress_wait">Please wait</string>
<string name="send_failure_title">Sending failure</string>
<string name="send_failure_amount_address_memo" formatted="true">Sending failed for:\n<xliff:g id="amount"
example="12.345">%1$s</xliff:g> ZEC to <xliff:g id="address" example="zs1g7cqw … mvyzgm">%2$s</xliff:g> with a memo: <xliff:g id="memo" example="for Veronika">%3$s</xliff:g></string>
<string name="send_failure_reason" formatted="true">Sending failed with:\n<xliff:g
id="reason" example="Insufficient balance">%1$s</xliff:g></string>
<string name="send_failure_button">Back</string>
<string name="send_successful_title">Sending successful</string>
<string name="send_successful_amount_address_memo" formatted="true">Sending succeeded for: <xliff:g id="amount" example="12.345">%1$s</xliff:g> ZEC to <xliff:g id="address" example="zs1g7cqw … mvyzgm">%2$s</xliff:g> with a memo: <xliff:g id="memo" example="for Veronika">%3$s</xliff:g></string>
<string name="send_successful_button">Close</string>
</resources> </resources>

View File

@ -0,0 +1,15 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="send_stage_confirmation_title">Confirmation</string>
<string name="send_confirmation_amount">Amount:</string>
<string name="send_confirmation_address">To:</string>
<string name="send_confirmation_memo">Message</string>
<string name="send_confirmation_fee">Fee:</string>
<string name="send_confirmation_send_button">Send</string>
<string name="send_confirmation_back_button">Go Back</string>
<string name="send_confirmation_dialog_error_title">Failed to send funds</string>
<string name="send_confirmation_dialog_error_text">Error: The attempt to send funds failed. Try it again, please.</string>
<string name="send_confirmation_dialog_error_btn">OK</string>
</resources>

View File

@ -466,7 +466,7 @@ private fun sendZecScreenshots(
composeTestRule.activity.walletViewModel.walletSnapshot.value != null composeTestRule.activity.walletViewModel.walletSnapshot.value != null
} }
composeTestRule.onNode(hasText(resContext.getString(R.string.send_title))).also { composeTestRule.onNode(hasText(resContext.getString(R.string.send_stage_send_title))).also {
it.assertExists() it.assertExists()
} }