[#1147] Show transaction memo

- Improves the screen UI so we’re able to call click, and query transaction memos
- These APIs will be useful once we approach screen refactoring according to the Figma design
- Adds UI test for the new feature
- Closes #1147

Changelog update
This commit is contained in:
Honza Rychnovský 2024-01-02 09:03:12 +01:00 committed by GitHub
parent d4fdb9aec2
commit b544de316d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 223 additions and 105 deletions

View File

@ -9,6 +9,9 @@ directly impact users rather than highlighting other key architectural updates.*
## [Unreleased]
### Added
- Transaction history items now display Memos within the Android Toast, triggered by clicking the item
## [0.2.0 (517)] - 2023-12-21
### Changed

View File

@ -104,15 +104,20 @@ fun TitleLarge(
}
@Composable
@Suppress("LongParameterList")
fun Small(
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.bodyMedium,

View File

@ -10,11 +10,17 @@ class HistoryTestSetup(
private val composeTestRule: ComposeContentTestRule,
initialHistorySyncState: TransactionHistorySyncState
) {
private val onBackCount = AtomicInteger(0)
private val onBackClickCount = AtomicInteger(0)
private val onItemClickCount = AtomicInteger(0)
fun getOnBackCount(): Int {
fun getOnBackClickCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
return onBackClickCount.get()
}
fun getOnItemClickCount(): Int {
composeTestRule.waitForIdle()
return onItemClickCount.get()
}
init {
@ -22,8 +28,11 @@ class HistoryTestSetup(
ZcashTheme {
History(
transactionState = initialHistorySyncState,
goBack = {
onBackCount.incrementAndGet()
onBack = {
onBackClickCount.incrementAndGet()
},
onItemClick = {
onItemClickCount.incrementAndGet()
}
)
}

View File

@ -2,6 +2,7 @@ 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.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
@ -105,10 +106,10 @@ class HistoryViewTest {
@Test
@MediumTest
fun back() {
fun back_click_test() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnBackCount())
assertEquals(0, testSetup.getOnBackClickCount())
composeTestRule.onNodeWithContentDescription(
getStringResource(R.string.history_back_content_description)
@ -116,7 +117,23 @@ class HistoryViewTest {
it.performClick()
}
assertEquals(1, testSetup.getOnBackCount())
assertEquals(1, testSetup.getOnBackClickCount())
}
@Test
@MediumTest
fun item_click_test() {
val testSetup = newTestSetup(TransactionHistorySyncStateFixture.STATE)
assertEquals(0, testSetup.getOnItemClickCount())
composeTestRule.onAllNodesWithTag(HistoryTag.TRANSACTION_ITEM).also {
TransactionHistorySyncStateFixture.TRANSACTIONS.forEachIndexed { index, _ ->
it[index].performClick()
}
}
assertEquals(TransactionHistorySyncStateFixture.TRANSACTIONS.size, testSetup.getOnItemClickCount())
}
private fun newTestSetup(

View File

@ -43,7 +43,9 @@ internal fun WrapAccount(
val isFiatConversionEnabled = ConfigurationEntries.IS_FIAT_CONVERSION_ENABLED.getValue(RemoteConfig.current)
if (null == walletSnapshot) {
// Improve this by allowing screen composition and updating it after the data is available
// 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 {
Account(

View File

@ -28,7 +28,9 @@ private fun WrapWalletAddresses(
val walletAddresses = walletViewModel.addresses.collectAsStateWithLifecycle().value
if (null == walletAddresses) {
// Improve this by allowing screen composition and updating it after the data is available
// 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 {
WalletAddresses(

View File

@ -46,7 +46,9 @@ internal fun WrapExportPrivateData(
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
if (synchronizer == null) {
// Improve this by allowing screen composition and updating it after the data is available
// 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 {
val snackbarHostState = remember { SnackbarHostState() }

View File

@ -3,11 +3,17 @@ package co.electriccoin.zcash.ui.screen.history
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.internal.Twig
import co.electriccoin.zcash.spackle.ClipboardManagerUtil
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.history.view.History
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapHistory(goBack: () -> Unit) {
@ -22,15 +28,41 @@ internal fun WrapHistory(
activity: ComponentActivity,
goBack: () -> Unit
) {
val queryScope = rememberCoroutineScope()
val walletViewModel by activity.viewModels<WalletViewModel>()
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val transactionHistoryState =
walletViewModel.transactionHistoryState.collectAsStateWithLifecycle().value
Twig.info { "Current transaction history state: $transactionHistoryState" }
History(
transactionState = transactionHistoryState,
goBack = goBack
)
if (synchronizer == null) {
// 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 {
History(
transactionState = transactionHistoryState,
onBack = goBack,
onItemClick = { tx ->
Twig.debug { "Querying transaction memos..." }
val memos = synchronizer.getMemos(tx)
queryScope.launch {
memos.toList().run {
val merged = joinToString().ifEmpty { "-" }
Twig.info { "Transaction memos: count: $size, contains: $merged" }
ClipboardManagerUtil.copyToClipboard(
activity.applicationContext,
activity.getString(R.string.history_item_clipboard_tag),
merged
)
}
}
},
)
}
}

View File

@ -5,5 +5,6 @@ package co.electriccoin.zcash.ui.screen.history
*/
object HistoryTag {
const val TRANSACTION_LIST = "transaction_list"
const val TRANSACTION_ITEM = "transaction_item"
const val PROGRESS = "progress_bar"
}

View File

@ -1,19 +1,18 @@
package co.electriccoin.zcash.ui.screen.history.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
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.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Cancel
@ -22,6 +21,8 @@ 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.Divider
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -65,7 +66,8 @@ private fun ComposablePreview() {
GradientSurface {
History(
transactionState = TransactionHistorySyncState.Loading,
goBack = {}
onBack = {},
onItemClick = {}
)
}
}
@ -86,7 +88,8 @@ private fun ComposableHistoryListPreview() {
TransactionOverviewFixture.new(netValue = Zatoshi(300000000)),
)
),
goBack = {}
onBack = {},
onItemClick = {}
)
}
}
@ -103,13 +106,15 @@ val dateFormat: DateFormat by lazy {
@Composable
fun History(
transactionState: TransactionHistorySyncState,
goBack: () -> Unit
onBack: () -> Unit,
onItemClick: (TransactionOverview) -> Unit
) {
Scaffold(topBar = {
HistoryTopBar(onBack = goBack)
HistoryTopBar(onBack = onBack)
}) { paddingValues ->
HistoryMainContent(
transactionState = transactionState,
onItemClick = onItemClick,
modifier =
Modifier
.fillMaxHeight()
@ -142,6 +147,7 @@ private fun HistoryTopBar(onBack: () -> Unit) {
@Composable
private fun HistoryMainContent(
transactionState: TransactionHistorySyncState,
onItemClick: (TransactionOverview) -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxSize()) {
@ -169,7 +175,10 @@ private fun HistoryMainContent(
end = ZcashTheme.dimens.spacingDefault
)
)
HistoryList(transactions = transactionState.transactions)
HistoryList(
transactions = transactionState.transactions,
onItemClick = onItemClick
)
}
// Add progress indicator only in the state of empty transaction
if (transactionState.hasNoTransactions()) {
@ -191,7 +200,10 @@ private fun HistoryMainContent(
.align(alignment = Center)
)
} else {
HistoryList(transactions = transactionState.transactions)
HistoryList(
transactions = transactionState.transactions,
onItemClick = onItemClick
)
}
}
}
@ -199,17 +211,25 @@ private fun HistoryMainContent(
}
@Composable
private fun HistoryList(transactions: ImmutableList<TransactionOverview>) {
private fun HistoryList(
transactions: ImmutableList<TransactionOverview>,
onItemClick: (TransactionOverview) -> Unit
) {
val currency = ZcashCurrency.fromResources(LocalContext.current)
LazyColumn(
contentPadding = PaddingValues(all = ZcashTheme.dimens.spacingDefault),
modifier = Modifier.testTag(HistoryTag.TRANSACTION_LIST)
) {
items(transactions) {
LazyColumn(modifier = Modifier.testTag(HistoryTag.TRANSACTION_LIST)) {
itemsIndexed(transactions) { index, item ->
HistoryItem(
transaction = it,
currency = currency
transaction = item,
currency = currency,
onItemClick = onItemClick,
modifier = Modifier.testTag(HistoryTag.TRANSACTION_ITEM)
)
if (index < transactions.lastIndex) {
Divider(
color = ZcashTheme.colors.dividerColor,
thickness = DividerDefaults.Thickness
)
}
}
}
}
@ -218,86 +238,99 @@ private fun HistoryList(transactions: ImmutableList<TransactionOverview>) {
@Suppress("LongMethod")
fun HistoryItem(
transaction: TransactionOverview,
currency: ZcashCurrency
currency: ZcashCurrency,
onItemClick: (TransactionOverview) -> Unit,
modifier: Modifier = Modifier
) {
val transactionTypeText: String
val transactionTypeIcon: ImageVector
when (transaction.getExtendedState()) {
TransactionExtendedState.SENT -> {
transactionTypeText = stringResource(id = R.string.history_item_sent)
transactionTypeIcon = Icons.TwoTone.ArrowCircleUp
}
TransactionExtendedState.SENDING -> {
transactionTypeText = stringResource(id = R.string.history_item_sending)
transactionTypeIcon = Icons.Outlined.ArrowCircleUp
}
TransactionExtendedState.RECEIVED -> {
transactionTypeText = stringResource(id = R.string.history_item_received)
transactionTypeIcon = Icons.TwoTone.ArrowCircleDown
}
TransactionExtendedState.RECEIVING -> {
transactionTypeText = stringResource(id = R.string.history_item_receiving)
transactionTypeIcon = Icons.Outlined.ArrowCircleDown
}
TransactionExtendedState.EXPIRED -> {
transactionTypeText = stringResource(id = R.string.history_item_expired)
transactionTypeIcon = Icons.Filled.Cancel
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = ZcashTheme.dimens.spacingSmall),
modifier.then(
Modifier
.fillMaxWidth()
.clickable { onItemClick(transaction) }
.padding(
horizontal = ZcashTheme.dimens.spacingDefault,
vertical = ZcashTheme.dimens.spacingDefault
)
),
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,
imageVector = transactionTypeIcon,
contentDescription = transactionTypeText,
modifier = Modifier.padding(all = ZcashTheme.dimens.spacingTiny)
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingTiny))
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Body(
text = transactionTypeText,
color = Color.Black
)
Column(
modifier = Modifier.weight(1f)
) {
Body(
text = transactionText,
color = Color.Black
)
val dateString =
transaction.minedHeight?.let {
transaction.blockTimeEpochSeconds?.let { blockTimeEpochSeconds ->
// * 1000 to covert to millis
@Suppress("MagicNumber")
dateFormat.format(blockTimeEpochSeconds.times(1000L))
} ?: stringResource(id = R.string.history_item_date_not_available)
} ?: stringResource(id = R.string.history_item_date_not_available)
// For now, use the same label for the above missing transaction date
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Body(
text = dateString,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val dateString =
transaction.minedHeight?.let {
transaction.blockTimeEpochSeconds?.let { blockTimeEpochSeconds ->
// * 1000 to covert to millis
@Suppress("MagicNumber")
dateFormat.format(blockTimeEpochSeconds.times(1000L))
} ?: stringResource(id = R.string.history_item_date_not_available)
} ?: stringResource(id = R.string.history_item_date_not_available)
// For now, use the same label for the above missing transaction date
Column {
Row(modifier = Modifier.align(alignment = Alignment.End)) {
val zecString =
if (transaction.isSentTransaction) {
"-${transaction.netValue.toZecString()}"
} else {
transaction.netValue.toZecString()
}
Body(text = zecString)
Body(
text = dateString,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingTiny))
Column {
Row(modifier = Modifier.align(alignment = Alignment.End)) {
val zecString =
if (transaction.isSentTransaction) {
"-${transaction.netValue.toZecString()}"
} else {
transaction.netValue.toZecString()
Body(text = currency.name)
}
Body(text = zecString)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingTiny))
Body(text = currency.name)
}
}
}
}

View File

@ -130,7 +130,6 @@ fun Home(
thickness = DividerDefaults.Thickness,
color = ZcashTheme.colors.dividerColor
)
TabRow(
selectedTabIndex = pagerState.currentPage,
// Don't use the predefined divider, as it's fixed position is below the tabs bar

View File

@ -34,7 +34,9 @@ internal fun WrapReceive(
onAddressDetails: () -> Unit,
) {
if (null == walletAddresses) {
// Improve this by allowing screen composition and updating it after the data is available
// 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 {
Receive(

View File

@ -30,7 +30,9 @@ private fun WrapRequest(
val walletAddresses = walletViewModel.addresses.collectAsStateWithLifecycle().value
if (null == walletAddresses) {
// Improve this by allowing screen composition and updating it after the data is available
// 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 {
Request(

View File

@ -41,7 +41,9 @@ fun WrapScan(
val scope = rememberCoroutineScope()
if (synchronizer == null) {
// Improve this by allowing screen composition and updating it after the data is available
// 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 {
Scan(

View File

@ -40,7 +40,9 @@ private fun WrapSeedRecovery(
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
if (null == synchronizer || null == persistableWallet) {
// Improve this by allowing screen composition and updating it after the data is available
// 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 {
SeedRecovery(

View File

@ -100,7 +100,9 @@ internal fun WrapSend(
}
if (null == synchronizer || null == spendableBalance || null == spendingKey) {
// Improve this by allowing screen composition and updating it after the data is available
// 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 {
Send(

View File

@ -72,7 +72,9 @@ private fun WrapSettings(
null == isBackgroundSyncEnabled ||
null == isKeepScreenOnWhileSyncing
) {
// Improve this by allowing screen composition and updating it after the data is available
// 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 {
Settings(

View File

@ -3,6 +3,7 @@
<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_clipboard_tag">Transaction memo</string>
<string name="history_item_sent">Sent</string>
<string name="history_item_received">Received</string>