[#664] Transaction History List

* [#664] Transaction history

* Move under the screens folder

* Fix Request screen Preview

* Add TODO link

* Improve Text design component

* HistoryView UI enhancing

* Adopt ZcashCurrency API

* Add transaction history sync state

* Compact time format

* Bump Compose Material Icons to v1.5.0-beta02

* Add support for pending and expired transactions

* Add progress in syncing with no transaction yet

* Screenshot test

* Simplified no transaction check

* Transaction history manual test case

* Home screen history button test

* Fix flow collecting

* View tests

* Sent transaction sign

* Remove unused transaction snapshot from VM

---------

Co-authored-by: Honza <rychnovsky.honza@gmail.com>
This commit is contained in:
Alex 2023-07-04 13:24:07 +02:00 committed by GitHub
parent 2b55b1df4f
commit fc7321e049
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 695 additions and 70 deletions

View File

@ -0,0 +1,18 @@
# Check transactions appear in the Transaction History screen
## Test Prerequisites
- Testnet wallet seed phrase mnemonic:
`board palm case fever fuel above dinosaur caution erode search ignore damage print rare lady agent stereo tomorrow end thank hurry deputy swamp wild`
- And its birthday height: `2379900`
- Install the latest Testnet variant wallet app
- Open the wallet app and for restoring its previous state, use the wallet information above
## Test
- Click on the _Transaction History_ button on the _Home_ screen (an additional scroll down may be needed)
- **Confirm** there are no transactions displayed. There should be only a text saying that the transaction may
appear once the sync completes.
- Go back to the _Home_ screen
- **Wait** for a few minutes to sync completes and _Up-to-date_ is displayed under the current balance text
- Go to the _Transaction History_ screen again
- **Confirm** that there are some transactions displayed. There should be some incoming as well as outgoing
transactions displayed.

View File

@ -120,7 +120,7 @@ ANDROIDX_APPCOMPAT_VERSION=1.6.1
ANDROIDX_CAMERA_VERSION=1.3.0-alpha06
ANDROIDX_COMPOSE_COMPILER_VERSION=1.4.7
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.1.0-rc01
ANDROIDX_COMPOSE_MATERIAL_ICONS_VERSION=1.4.3
ANDROIDX_COMPOSE_MATERIAL_ICONS_VERSION=1.5.0-beta02
ANDROIDX_COMPOSE_VERSION=1.4.3
ANDROIDX_CONSTRAINTLAYOUT_VERSION=1.0.1
ANDROIDX_CORE_VERSION=1.9.0

View File

@ -10,6 +10,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@ -30,15 +31,20 @@ fun Header(
}
@Composable
@Suppress("LongParameterList")
fun Body(
text: String,
modifier: Modifier = Modifier,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
textAlign: TextAlign = TextAlign.Start,
color: Color = MaterialTheme.colorScheme.onBackground,
) {
Text(
text = text,
color = color,
maxLines = maxLines,
overflow = overflow,
textAlign = textAlign,
modifier = modifier,
style = MaterialTheme.typography.bodyLarge,

View File

@ -30,6 +30,7 @@ android {
"src/main/res/ui/about",
"src/main/res/ui/backup",
"src/main/res/ui/common",
"src/main/res/ui/history",
"src/main/res/ui/home",
"src/main/res/ui/onboarding",
"src/main/res/ui/receive",

View File

@ -2,7 +2,6 @@ package co.electriccoin.zcash.ui.common
import androidx.test.filters.FlakyTest
import androidx.test.filters.SmallTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
@ -17,7 +16,7 @@ import kotlin.time.TimeSource
class FlowExtTest {
@OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class)
@OptIn(ExperimentalTime::class)
@Test
@SmallTest
fun throttle_one_sec() = runTest {

View File

@ -0,0 +1,33 @@
package co.electriccoin.zcash.ui.screen.history
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.history.state.TransactionHistorySyncState
import co.electriccoin.zcash.ui.screen.history.view.History
import java.util.concurrent.atomic.AtomicInteger
class HistoryTestSetup(
private val composeTestRule: ComposeContentTestRule,
initialHistorySyncState: TransactionHistorySyncState
) {
private val onBackCount = AtomicInteger(0)
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
}
init {
composeTestRule.setContent {
ZcashTheme {
History(
transactionState = initialHistorySyncState,
goBack = {
onBackCount.incrementAndGet()
}
)
}
}
}
}

View File

@ -0,0 +1,31 @@
package co.electriccoin.zcash.ui.screen.history.fixture
import cash.z.ecc.android.sdk.fixture.TransactionOverviewFixture
import cash.z.ecc.android.sdk.model.TransactionOverview
import co.electriccoin.zcash.ui.screen.history.state.TransactionHistorySyncState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal object TransactionHistorySyncStateFixture {
val TRANSACTIONS = persistentListOf(
TransactionOverviewFixture.new(id = 0),
TransactionOverviewFixture.new(id = 1),
TransactionOverviewFixture.new(id = 2)
)
val STATE = TransactionHistorySyncState.Syncing(TRANSACTIONS)
fun new(
transactions: ImmutableList<TransactionOverview> = TRANSACTIONS,
state: TransactionHistorySyncState = STATE
) = when (state) {
is TransactionHistorySyncState.Syncing -> {
state.copy(transactions)
}
is TransactionHistorySyncState.Done -> {
state.copy(transactions)
}
TransactionHistorySyncState.Loading -> {
state
}
}
}

View File

@ -0,0 +1,128 @@
package co.electriccoin.zcash.ui.screen.history.view
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.history.HistoryTag
import co.electriccoin.zcash.ui.screen.history.HistoryTestSetup
import co.electriccoin.zcash.ui.screen.history.fixture.TransactionHistorySyncStateFixture
import co.electriccoin.zcash.ui.screen.history.state.TransactionHistorySyncState
import co.electriccoin.zcash.ui.test.getStringResource
import kotlinx.collections.immutable.persistentListOf
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class HistoryViewTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun check_loading_state() {
newTestSetup(TransactionHistorySyncState.Loading)
composeTestRule.onNodeWithTag(HistoryTag.PROGRESS).also {
it.assertExists()
}
}
@Test
@MediumTest
fun check_syncing_state() {
newTestSetup(
TransactionHistorySyncStateFixture.new(
state = TransactionHistorySyncStateFixture.STATE,
transactions = TransactionHistorySyncStateFixture.TRANSACTIONS
)
)
composeTestRule.onNodeWithText(getStringResource(R.string.history_syncing)).also {
it.assertExists()
}
// No progress bar, as we have some transactions laid out
composeTestRule.onNodeWithTag(HistoryTag.PROGRESS).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithTag(HistoryTag.TRANSACTION_LIST).also {
it.assertExists()
it.assertHeightIsAtLeast(1.dp)
}
}
@Test
@MediumTest
fun check_done_state_no_transactions() {
newTestSetup(
TransactionHistorySyncStateFixture.new(
state = TransactionHistorySyncState.Done(persistentListOf()),
transactions = persistentListOf()
)
)
composeTestRule.onNodeWithText(getStringResource(R.string.history_syncing)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithTag(HistoryTag.PROGRESS).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithTag(HistoryTag.TRANSACTION_LIST).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.history_empty)).also {
it.assertExists()
}
}
@Test
@MediumTest
fun check_done_state_with_transactions() {
newTestSetup(
TransactionHistorySyncStateFixture.new(
state = TransactionHistorySyncState.Done(persistentListOf()),
transactions = TransactionHistorySyncStateFixture.TRANSACTIONS
)
)
composeTestRule.onNodeWithText(getStringResource(R.string.history_syncing)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithTag(HistoryTag.PROGRESS).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithTag(HistoryTag.TRANSACTION_LIST).also {
it.assertExists()
it.assertHeightIsAtLeast(1.dp)
}
composeTestRule.onNodeWithText(getStringResource(R.string.history_empty)).also {
it.assertDoesNotExist()
}
}
@Test
@MediumTest
fun back() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.onNodeWithContentDescription(
getStringResource(R.string.history_back_content_description)
).also {
it.performClick()
}
assertEquals(1, testSetup.getOnBackCount())
}
private fun newTestSetup(
transactionHistorySyncState: TransactionHistorySyncState = TransactionHistorySyncStateFixture.new()
) = HistoryTestSetup(
composeTestRule = composeTestRule,
initialHistorySyncState = transactionHistorySyncState
)
}

View File

@ -5,7 +5,6 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
import co.electriccoin.zcash.ui.screen.home.view.Home
import kotlinx.collections.immutable.persistentListOf
import java.util.concurrent.atomic.AtomicInteger
class HomeTestSetup(
@ -20,6 +19,7 @@ class HomeTestSetup(
private val onSupportCount = AtomicInteger(0)
private val onReceiveCount = AtomicInteger(0)
private val onSendCount = AtomicInteger(0)
private val onHistoryCount = AtomicInteger(0)
fun getOnAboutCount(): Int {
composeTestRule.waitForIdle()
@ -51,6 +51,11 @@ class HomeTestSetup(
return onSendCount.get()
}
fun getOnHistoryCount(): Int {
composeTestRule.waitForIdle()
return onHistoryCount.get()
}
fun getWalletSnapshot(): WalletSnapshot {
composeTestRule.waitForIdle()
return walletSnapshot
@ -62,7 +67,6 @@ class HomeTestSetup(
val drawerValues = drawerBackHandler()
Home(
walletSnapshot,
transactionHistory = persistentListOf(),
isUpdateAvailable = false,
isKeepScreenOnDuringSync = false,
isFiatConversionEnabled = isShowFiatConversion,
@ -86,6 +90,9 @@ class HomeTestSetup(
goSend = {
onSendCount.incrementAndGet()
},
goHistory = {
onHistoryCount.incrementAndGet()
},
resetSdk = {},
drawerState = drawerValues.drawerState,
scope = drawerValues.scope

View File

@ -121,6 +121,18 @@ class HomeViewTest : UiTestPrerequisites() {
assertEquals(1, testSetup.getOnSendCount())
}
@Test
@MediumTest
fun click_history_button() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnHistoryCount())
composeTestRule.clickHistory()
assertEquals(1, testSetup.getOnHistoryCount())
}
@Test
@MediumTest
fun hamburger_seed() {
@ -217,3 +229,10 @@ private fun ComposeContentTestRule.clickSend() {
it.performClick()
}
}
private fun ComposeContentTestRule.clickHistory() {
onNodeWithText(getStringResource(R.string.home_button_history)).also {
it.performScrollTo()
it.performClick()
}
}

View File

@ -12,6 +12,7 @@ import co.electriccoin.zcash.ui.NavigationArguments.SEND_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.SEND_MEMO
import co.electriccoin.zcash.ui.NavigationArguments.SEND_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.HISTORY
import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.RECEIVE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
@ -25,6 +26,7 @@ import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.screen.about.WrapAbout
import co.electriccoin.zcash.ui.screen.address.WrapWalletAddresses
import co.electriccoin.zcash.ui.screen.history.WrapHistory
import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.receive.WrapReceive
import co.electriccoin.zcash.ui.screen.request.WrapRequest
@ -54,6 +56,7 @@ internal fun MainActivity.Navigation() {
goAbout = { navController.navigateJustOnce(ABOUT) },
goReceive = { navController.navigateJustOnce(RECEIVE) },
goSend = { navController.navigateJustOnce(SEND) },
goHistory = { navController.navigateJustOnce(HISTORY) }
)
if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) {
@ -128,6 +131,10 @@ internal fun MainActivity.Navigation() {
goBack = { navController.popBackStackJustOnce(SCAN) }
)
}
composable(HISTORY) {
WrapHistory(goBack = { navController.navigateUp() })
}
}
}
@ -178,6 +185,8 @@ object NavigationTargets {
const val REQUEST = "request"
const val HISTORY = "history"
const val SEND = "send"
const val SUPPORT = "support"

View File

@ -0,0 +1,38 @@
package co.electriccoin.zcash.ui.screen.history
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.internal.Twig
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.screen.history.view.History
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
@Composable
internal fun MainActivity.WrapHistory(
goBack: () -> Unit
) {
WrapHistory(
activity = this,
goBack = goBack
)
}
@Composable
internal fun WrapHistory(
activity: ComponentActivity,
goBack: () -> Unit
) {
val walletViewModel by activity.viewModels<WalletViewModel>()
val transactionHistoryState =
walletViewModel.transactionHistoryState.collectAsStateWithLifecycle().value
Twig.info { "Current transaction history state: $transactionHistoryState" }
History(
transactionState = transactionHistoryState,
goBack = goBack
)
}

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.ui.screen.history
/**
* These are only used for automated testing.
*/
object HistoryTag {
const val TRANSACTION_LIST = "transaction_list"
const val PROGRESS = "progress_bar"
}

View File

@ -0,0 +1,21 @@
package co.electriccoin.zcash.ui.screen.history.state
import cash.z.ecc.android.sdk.model.TransactionOverview
import kotlinx.collections.immutable.ImmutableList
sealed class TransactionHistorySyncState {
object Loading : TransactionHistorySyncState() {
override fun toString() = "Loading" // NON-NLS
}
data class Syncing(val transactions: ImmutableList<TransactionOverview>) : TransactionHistorySyncState() {
fun hasNoTransactions(): Boolean {
return transactions.isEmpty()
}
}
data class Done(val transactions: ImmutableList<TransactionOverview>) : TransactionHistorySyncState() {
fun hasNoTransactions(): Boolean {
return transactions.isEmpty()
}
}
}

View File

@ -0,0 +1,300 @@
package co.electriccoin.zcash.ui.screen.history.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.outlined.ArrowCircleDown
import androidx.compose.material.icons.outlined.ArrowCircleUp
import androidx.compose.material.icons.twotone.ArrowCircleDown
import androidx.compose.material.icons.twotone.ArrowCircleUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Alignment.Companion.TopCenter
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionState
import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.history.HistoryTag
import co.electriccoin.zcash.ui.screen.history.state.TransactionHistorySyncState
import kotlinx.collections.immutable.ImmutableList
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Locale
@Preview("History")
@Composable
private fun ComposablePreview() {
ZcashTheme(darkTheme = true) {
GradientSurface {
History(
transactionState = TransactionHistorySyncState.Loading,
goBack = {}
)
}
}
}
val dateFormat: DateFormat by lazy {
SimpleDateFormat.getDateTimeInstance(
SimpleDateFormat.MEDIUM,
SimpleDateFormat.SHORT,
Locale.getDefault()
)
}
@Composable
fun History(
transactionState: TransactionHistorySyncState,
goBack: () -> Unit
) {
Scaffold(topBar = {
HistoryTopBar(onBack = goBack)
}) { paddingValues ->
HistoryMainContent(
transactionState = transactionState,
modifier = Modifier
.fillMaxHeight()
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
)
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun HistoryTopBar(onBack: () -> Unit) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.history_title)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.history_back_content_description)
)
}
}
)
}
@Composable
private fun HistoryMainContent(
transactionState: TransactionHistorySyncState,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxSize()) {
when (transactionState) {
is TransactionHistorySyncState.Loading -> {
CircularProgressIndicator(
modifier = Modifier
.align(alignment = Center)
.testTag(HistoryTag.PROGRESS)
)
}
is TransactionHistorySyncState.Syncing -> {
Column(
modifier = Modifier.align(alignment = TopCenter)
) {
Body(
text = stringResource(id = R.string.history_syncing),
modifier = Modifier
.padding(
top = ZcashTheme.dimens.spacingSmall,
bottom = ZcashTheme.dimens.spacingSmall,
start = ZcashTheme.dimens.spacingDefault,
end = ZcashTheme.dimens.spacingDefault
)
)
HistoryList(transactions = transactionState.transactions)
}
// Add progress indicator only in the state of empty transaction
if (transactionState.hasNoTransactions()) {
CircularProgressIndicator(
modifier = Modifier
.align(alignment = Center)
.testTag(HistoryTag.PROGRESS)
)
}
}
is TransactionHistorySyncState.Done -> {
if (transactionState.hasNoTransactions()) {
Body(
text = stringResource(id = R.string.history_empty),
modifier = Modifier
.padding(all = ZcashTheme.dimens.spacingDefault)
.align(alignment = Center)
)
} else {
HistoryList(transactions = transactionState.transactions)
}
}
}
}
}
@Composable
private fun HistoryList(transactions: ImmutableList<TransactionOverview>) {
val currency = ZcashCurrency.fromResources(LocalContext.current)
LazyColumn(
contentPadding = PaddingValues(all = ZcashTheme.dimens.spacingDefault),
modifier = Modifier.testTag(HistoryTag.TRANSACTION_LIST)
) {
items(transactions) {
HistoryItem(
transaction = it,
currency = currency
)
}
}
}
@Composable
@Suppress("LongMethod")
fun HistoryItem(
transaction: TransactionOverview,
currency: ZcashCurrency
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = ZcashTheme.dimens.spacingSmall),
verticalAlignment = Alignment.CenterVertically
) {
val transactionText: String
val transactionIcon: ImageVector
when (transaction.getExtendedState()) {
TransactionExtendedState.SENT -> {
transactionText = stringResource(id = R.string.history_item_sent)
transactionIcon = Icons.TwoTone.ArrowCircleUp
}
TransactionExtendedState.SENDING -> {
transactionText = stringResource(id = R.string.history_item_sending)
transactionIcon = Icons.Outlined.ArrowCircleUp
}
TransactionExtendedState.RECEIVED -> {
transactionText = stringResource(id = R.string.history_item_received)
transactionIcon = Icons.TwoTone.ArrowCircleDown
}
TransactionExtendedState.RECEIVING -> {
transactionText = stringResource(id = R.string.history_item_receiving)
transactionIcon = Icons.Outlined.ArrowCircleDown
}
TransactionExtendedState.EXPIRED -> {
transactionText = stringResource(id = R.string.history_item_expired)
transactionIcon = Icons.Filled.Cancel
}
}
Image(
imageVector = transactionIcon,
contentDescription = transactionText,
modifier = Modifier.padding(all = ZcashTheme.dimens.spacingTiny)
)
Column(
modifier = Modifier.weight(1f)
) {
Body(
text = transactionText,
color = Color.Black
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
// * 1000 to covert to millis
@Suppress("MagicNumber")
val dateString = dateFormat.format(transaction.blockTimeEpochSeconds.times(1000))
Body(
text = dateString,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Column {
Row(modifier = Modifier.align(alignment = Alignment.End)) {
val zecString = if (transaction.isSentTransaction) {
"-${transaction.netValue.toZecString()}"
} else {
transaction.netValue.toZecString()
}
Body(text = zecString)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingTiny))
Body(text = currency.name)
}
}
}
}
enum class TransactionExtendedState {
SENT,
SENDING,
RECEIVED,
RECEIVING,
EXPIRED
}
private fun TransactionOverview.getExtendedState(): TransactionExtendedState {
return when (transactionState) {
TransactionState.Expired -> {
TransactionExtendedState.EXPIRED
}
TransactionState.Confirmed -> {
if (isSentTransaction) {
TransactionExtendedState.SENT
} else {
TransactionExtendedState.RECEIVED
}
}
TransactionState.Pending -> {
if (isSentTransaction) {
TransactionExtendedState.SENDING
} else {
TransactionExtendedState.RECEIVING
}
}
else -> {
error("Unexpected transaction state found while calculating its extended state.")
}
}
}

View File

@ -36,6 +36,7 @@ internal fun MainActivity.WrapHome(
goAbout: () -> Unit,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit
) {
WrapHome(
this,
@ -45,6 +46,7 @@ internal fun MainActivity.WrapHome(
goAbout = goAbout,
goReceive = goReceive,
goSend = goSend,
goHistory = goHistory,
)
}
@ -58,6 +60,7 @@ internal fun WrapHome(
goAbout: () -> Unit,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit,
) {
// we want to show information about app update, if available
val checkUpdateViewModel by activity.viewModels<CheckUpdateViewModel> {
@ -91,13 +94,10 @@ internal fun WrapHome(
!FirebaseTestLabUtil.isFirebaseTestLab(context) &&
!EmulatorWtfUtil.isEmulatorWtf(context)
val transactionSnapshot = walletViewModel.transactionSnapshot.collectAsStateWithLifecycle().value
val drawerValues = drawerBackHandler()
Home(
walletSnapshot,
transactionSnapshot,
isUpdateAvailable = updateAvailable,
isKeepScreenOnDuringSync = isKeepScreenOnWhileSyncing,
isFiatConversionEnabled = isFiatConversionEnabled,
@ -109,6 +109,7 @@ internal fun WrapHome(
goAbout = goAbout,
goReceive = goReceive,
goSend = goSend,
goHistory = goHistory,
resetSdk = {
walletViewModel.resetSdk()
},

View File

@ -9,6 +9,7 @@ import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.screen.home.viewmodel.SynchronizerError
// TODO [#292]: Should be moved to SDK-EXT-UI module.
// TODO [#292]: https://github.com/zcash/secant-android-wallet/issues/292
data class WalletSnapshot(
val status: Synchronizer.Status,
val processorInfo: CompactBlockProcessor.ProcessorInfo,

View File

@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@ -58,7 +56,6 @@ import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.FiatCurrencyConversionRateState
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.TransactionOverview
import co.electriccoin.zcash.crash.android.GlobalCrashReporter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.DisableScreenTimeout
@ -69,13 +66,12 @@ import co.electriccoin.zcash.ui.design.component.BodyWithFiatCurrencySymbol
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.HeaderWithZecIcon
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.TertiaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.home.HomeTag
import co.electriccoin.zcash.ui.screen.home.model.WalletDisplayValues
import co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
@Preview("Home")
@ -85,7 +81,6 @@ private fun ComposablePreview() {
GradientSurface {
Home(
walletSnapshot = WalletSnapshotFixture.new(),
transactionHistory = persistentListOf(),
isUpdateAvailable = false,
isKeepScreenOnDuringSync = false,
isDebugMenuEnabled = false,
@ -97,6 +92,7 @@ private fun ComposablePreview() {
goAbout = {},
goReceive = {},
goSend = {},
goHistory = {},
resetSdk = {},
drawerState = rememberDrawerState(DrawerValue.Closed),
scope = rememberCoroutineScope()
@ -109,7 +105,6 @@ private fun ComposablePreview() {
@Composable
fun Home(
walletSnapshot: WalletSnapshot,
transactionHistory: ImmutableList<TransactionOverview>,
isUpdateAvailable: Boolean,
isKeepScreenOnDuringSync: Boolean?,
isFiatConversionEnabled: Boolean,
@ -121,6 +116,7 @@ fun Home(
goAbout: () -> Unit,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit,
resetSdk: () -> Unit,
drawerState: DrawerState,
scope: CoroutineScope
@ -145,14 +141,14 @@ fun Home(
)
}) { paddingValues ->
HomeMainContent(
walletSnapshot,
transactionHistory,
walletSnapshot = walletSnapshot,
isUpdateAvailable = isUpdateAvailable,
isKeepScreenOnDuringSync = isKeepScreenOnDuringSync,
isFiatConversionEnabled = isFiatConversionEnabled,
isCircularProgressBarEnabled = isCircularProgressBarEnabled,
goReceive = goReceive,
goSend = goSend,
goHistory = goHistory,
modifier = Modifier.padding(
top = paddingValues.calculateTopPadding() + ZcashTheme.dimens.spacingDefault,
bottom = paddingValues.calculateBottomPadding() + ZcashTheme.dimens.spacingDefault,
@ -296,13 +292,13 @@ private fun HomeDrawer(
@Composable
private fun HomeMainContent(
walletSnapshot: WalletSnapshot,
transactionHistory: ImmutableList<TransactionOverview>,
isUpdateAvailable: Boolean,
isKeepScreenOnDuringSync: Boolean?,
isFiatConversionEnabled: Boolean,
isCircularProgressBarEnabled: Boolean,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
@ -339,7 +335,7 @@ private fun HomeMainContent(
)
)
History(transactionHistory)
TertiaryButton(onClick = goHistory, text = stringResource(R.string.home_button_history))
if (isKeepScreenOnDuringSync == true && walletSnapshot.status == Synchronizer.Status.SYNCING) {
DisableScreenTimeout()
@ -449,25 +445,3 @@ private fun Status(
}
}
}
@Composable
@Suppress("MagicNumber")
private fun History(transactionHistory: ImmutableList<TransactionOverview>) {
if (transactionHistory.isEmpty()) {
return
}
// here we need to use a fixed height to avoid nested columns vertical scrolling problem
// we'll refactor this part to a dedicated bottom sheet later
val historyPart = LocalConfiguration.current.screenHeightDp / 3
LazyColumn(
Modifier
.fillMaxWidth()
.height(historyPart.dp)
) {
items(transactionHistory) {
Text(it.toString())
}
}
}

View File

@ -28,9 +28,8 @@ import co.electriccoin.zcash.ui.preference.EncryptedPreferenceKeys
import co.electriccoin.zcash.ui.preference.EncryptedPreferenceSingleton
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
import co.electriccoin.zcash.ui.screen.history.state.TransactionHistorySyncState
import co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -148,22 +147,6 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
null
)
// This is not the right API, because the transaction list could be very long and might need UI filtering
@OptIn(ExperimentalCoroutinesApi::class)
val transactionSnapshot: StateFlow<ImmutableList<TransactionOverview>> = synchronizer
.flatMapLatest {
if (null == it) {
flowOf(persistentListOf())
} else {
it.transactions.map { list -> list.toPersistentList() }
}
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
persistentListOf()
)
val addresses: StateFlow<WalletAddresses?> = synchronizer
.filterNotNull()
.map {
@ -174,6 +157,25 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
null
)
@OptIn(ExperimentalCoroutinesApi::class)
val transactionHistoryState = synchronizer
.filterNotNull()
.flatMapLatest {
it.transactions
.combine(it.status) { transactions: List<TransactionOverview>, status: Synchronizer.Status ->
if (status.isSyncing()) {
TransactionHistorySyncState.Syncing(transactions.toPersistentList())
} else {
TransactionHistorySyncState.Done(transactions.toPersistentList())
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = TransactionHistorySyncState.Loading
)
/**
* Creates a wallet asynchronously and then persists it. Clients observe
* [secretState] to see the side effects. This would be used for a user creating a new wallet.
@ -347,3 +349,5 @@ private fun Synchronizer.toWalletSnapshot() =
flows[6] as SynchronizerError?
)
}
private fun Synchronizer.Status.isSyncing() = this == Synchronizer.Status.SYNCING

View File

@ -8,11 +8,11 @@ import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.spackle.EmulatorWtfUtil
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
@ -54,8 +54,8 @@ internal fun WrapOnboarding(
persistExistingWalletWithSeedPhrase(
applicationContext,
walletViewModel,
SeedPhraseFixture.new(),
birthday = null
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
birthday = WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(applicationContext))
)
} else {
walletViewModel.persistNewWallet()
@ -71,8 +71,8 @@ internal fun WrapOnboarding(
persistExistingWalletWithSeedPhrase(
applicationContext,
walletViewModel,
SeedPhraseFixture.new(),
birthday = null
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
birthday = WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(applicationContext))
)
} else {
onboardingViewModel.setIsImporting(true)
@ -83,8 +83,8 @@ internal fun WrapOnboarding(
persistExistingWalletWithSeedPhrase(
applicationContext,
walletViewModel,
SeedPhraseFixture.new(),
birthday = null
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
birthday = WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(applicationContext))
)
}

View File

@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.model.MonetarySeparators
@ -42,8 +43,9 @@ import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import kotlinx.coroutines.runBlocking
@Preview("Request")
@Composable
fun PreviewRequest() {
private fun PreviewRequest() {
ZcashTheme(darkTheme = true) {
GradientSurface {
Request(
@ -58,7 +60,6 @@ fun PreviewRequest() {
/**
* @param myAddress The address that ZEC should be sent to.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Request(
myAddress: WalletAddress.Unified,
@ -98,7 +99,6 @@ private fun RequestTopAppBar(onBack: () -> Unit) {
// TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button.
// TODO [#288]: TextField component can't do long-press backspace.
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun RequestMainContent(
paddingValues: PaddingValues,
myAddress: WalletAddress.Unified,

View File

@ -17,6 +17,7 @@ data class TimeInfo(
) {
// TODO [#388]: Consider fuzzing the times
// TODO [#388]: https://github.com/zcash/secant-android-wallet/issues/388
fun toSupportString() = buildString {
// Use a slightly more human friendly format instead of ISO, since this will appear in the emails that users see
val dateFormat = SimpleDateFormat("yyyy-MM-dd hh:mm:ss a", Locale.US) // $NON-NLS-1$

View File

@ -0,0 +1,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="history_title">Transaction history</string>
<string name="history_back_content_description">Back</string>
<string name="history_syncing">Additional transactions may be found after syncing completes…</string>
<string name="history_empty">No transactions yet</string>
<string name="history_item_sent">Sent</string>
<string name="history_item_received">Received</string>
<string name="history_item_sending">Sending</string>
<string name="history_item_receiving">Receiving</string>
<string name="history_item_expired">Expired</string>
</resources>

View File

@ -2,6 +2,7 @@
<string name="home_menu_content_description">Open menu</string>
<string name="home_button_receive">Receive</string>
<string name="home_button_send">Send</string>
<string name="home_button_history">Transaction History</string>
<string name="home_information">You wont be able to transfer funds until your wallet is finished syncing. Please keep your device plugged in and the app open.</string>
<string name="home_menu_seed_phrase">My secret phrase</string>

View File

@ -145,7 +145,7 @@ class ScreenshotTest : UiTestPrerequisites() {
}
// TODO [#859]: Screenshot tests fail on Firebase Test Lab
// https://github.com/zcash/secant-android-wallet/issues/859
// TODO [#859]: https://github.com/zcash/secant-android-wallet/issues/859
// Some of the restore screenshots broke with the Compose 1.4 update and we don't yet know why.
private val isRestoreScreenshotsEnabled = false
@ -312,6 +312,9 @@ class ScreenshotTest : UiTestPrerequisites() {
navigateTo(NavigationTargets.WALLET_ADDRESS_DETAILS)
addressDetailsScreenshots(resContext, tag, composeTestRule)
navigateTo(NavigationTargets.HISTORY)
transactionHistoryScreenshots(resContext, tag, composeTestRule)
}
}
@ -534,6 +537,14 @@ private fun addressDetailsScreenshots(resContext: Context, tag: String, composeT
ScreenshotTest.takeScreenshot(tag, "Addresses 1")
}
private fun transactionHistoryScreenshots(resContext: Context, tag: String, composeTestRule: ComposeTestRule) {
composeTestRule.onNode(hasText(resContext.getString(R.string.history_title))).also {
it.assertExists()
}
ScreenshotTest.takeScreenshot(tag, "Transaction History 1")
}
// This screen is not currently navigable from the app
@Suppress("UnusedPrivateMember")
private fun requestZecScreenshots(resContext: Context, tag: String, composeTestRule: ComposeTestRule) {