taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit 71b42e0fbb779853bd7cf68caba6c8fb1c9e36bd
parent c3fd3a371d9cc83ad9a5fc4cf8e87ee8ead42d9e
Author: Iván Ávalos <avalos@disroot.org>
Date:   Fri,  4 Apr 2025 16:21:11 +0200

[wallet] add separate view for pending transactions

Diffstat:
Mwallet/src/main/java/net/taler/wallet/MainFragment.kt | 8+++++++-
Mwallet/src/main/java/net/taler/wallet/MainViewModel.kt | 5+++--
Mwallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt | 146+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Awallet/src/main/java/net/taler/wallet/compose/Banner.kt | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt | 50++++++++++++++++++++++++++++++++++++++++++++------
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt | 17++---------------
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt | 22++++++++++++++++++++++
Mwallet/src/main/res/values/strings.xml | 5+++--
8 files changed, 233 insertions(+), 77 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt @@ -89,6 +89,7 @@ import net.taler.wallet.compose.GridMenuItem import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.settings.SettingsFragment +import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal import kotlin.math.roundToInt class MainFragment: Fragment() { @@ -114,7 +115,8 @@ class MainFragment: Fragment() { val context = LocalContext.current val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) val selectedScope by model.transactionManager.selectedScope.collectAsStateLifecycleAware() - val txResult by remember(selectedScope) { model.transactionManager.transactionsFlow(selectedScope) }.collectAsStateLifecycleAware() + val txStateFilter by model.transactionManager.stateFilter.collectAsStateLifecycleAware() + val txResult by remember(selectedScope, txStateFilter) { model.transactionManager.transactionsFlow(selectedScope, stateFilter = txStateFilter) }.collectAsStateLifecycleAware() val selectedSpec = remember(selectedScope) { selectedScope?.let { model.balanceManager.getSpecForScopeInfo(it) } } val actionButtonUsed by remember { model.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true) @@ -173,6 +175,7 @@ class MainFragment: Fragment() { innerPadding = innerPadding, state = balanceState, txResult = txResult, + txStateFilter = txStateFilter, selectedScope = selectedScope, selectedCurrencySpec = selectedSpec, onGetDemoMoneyClicked = { @@ -182,6 +185,9 @@ class MainFragment: Fragment() { onBalanceClicked = { model.showTransactions(it.scopeInfo) }, + onPendingClicked = { + model.showTransactions(it.scopeInfo, Nonfinal) + }, onTransactionClicked = { tx -> if (tx.detailPageNav != 0) { model.transactionManager.selectTransaction(tx) diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -57,6 +57,7 @@ import net.taler.wallet.refund.RefundManager import net.taler.wallet.settings.SettingsManager import net.taler.wallet.settings.userPreferencesDataStore import net.taler.wallet.transactions.TransactionManager +import net.taler.wallet.transactions.TransactionStateFilter import net.taler.wallet.withdraw.WithdrawManager import org.json.JSONObject @@ -184,9 +185,9 @@ class MainViewModel( * Navigates to the given scope info's transaction list, when [MainFragment] is shown. */ @UiThread - fun showTransactions(scopeInfo: ScopeInfo) { + fun showTransactions(scopeInfo: ScopeInfo, stateFilter: TransactionStateFilter? = null) { Log.d(TAG, "selectedScope should change to $scopeInfo") - transactionManager.selectScope(scopeInfo) + transactionManager.selectScope(scopeInfo, stateFilter) } @UiThread diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt @@ -29,8 +29,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.ProvideTextStyle @@ -54,6 +59,7 @@ import net.taler.wallet.cleanExchange import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.transactions.Transaction +import net.taler.wallet.transactions.TransactionStateFilter import net.taler.wallet.transactions.TransactionsComposable import net.taler.wallet.transactions.TransactionsResult import net.taler.wallet.withdraw.WithdrawalError @@ -63,10 +69,12 @@ fun BalancesComposable( innerPadding: PaddingValues, state: BalanceState, txResult: TransactionsResult, + txStateFilter: TransactionStateFilter?, selectedScope: ScopeInfo?, selectedCurrencySpec: CurrencySpecification?, onGetDemoMoneyClicked: () -> Unit, onBalanceClicked: (balance: BalanceItem) -> Unit, + onPendingClicked: (balance: BalanceItem) -> Unit, onTransactionClicked: (tx: Transaction) -> Unit, onTransactionsDelete: (txIds: List<String>) -> Unit, onShowBalancesClicked: () -> Unit, @@ -84,9 +92,10 @@ fun BalancesComposable( contentPadding = innerPadding, ) { items(state.balances, key = { it.scopeInfo.hashCode() }) { balance -> - BalanceRow(balance) { - onBalanceClicked(balance) - } + BalanceRow(balance, + onClick = { onBalanceClicked(balance) }, + onPendingClick = { onPendingClicked(balance) }, + ) } } } else { @@ -100,6 +109,7 @@ fun BalancesComposable( balance = it, currencySpec = selectedCurrencySpec, txResult = txResult, + txStateFilter = txStateFilter, onTransactionClick = onTransactionClicked, onTransactionsDelete = onTransactionsDelete, onShowBalancesClicked = onShowBalancesClicked, @@ -121,75 +131,105 @@ fun BalancesComposable( fun BalanceRow( balance: BalanceItem, onClick: () -> Unit, + onPendingClick: () -> Unit, ) { OutlinedCard( modifier = Modifier .padding( horizontal = 9.dp, vertical = 6.dp, - ).clickable { onClick() }, + ) ) { - ListItem( - modifier = Modifier - .animateContentSize() - .padding(6.dp), - headlineContent = { - Text( - balance.available.toString(), - style = MaterialTheme.typography.displaySmall, - ) - }, - overlineContent = { - ProvideTextStyle(MaterialTheme.typography.bodySmall) { - when (balance.scopeInfo) { - is Exchange -> Text( - stringResource( - R.string.balance_scope_exchange, - cleanExchange(balance.scopeInfo.url) - ), - ) - - is Auditor -> Text( - stringResource( - R.string.balance_scope_auditor, - cleanExchange(balance.scopeInfo.url) - ), - ) - - else -> {} - } - } - }, - supportingContent = { - Column { - ProvideTextStyle(MaterialTheme.typography.bodyLarge) { - AnimatedVisibility(!balance.pendingIncoming.isZero()) { - Text( + Column { + ListItem( + modifier = Modifier + .animateContentSize() + .clickable { onClick() } + .padding(vertical = 6.dp), + headlineContent = { + Text( + balance.available.toString(), + style = MaterialTheme.typography.displaySmall, + ) + }, + overlineContent = { + ProvideTextStyle(MaterialTheme.typography.bodySmall) { + when (balance.scopeInfo) { + is Exchange -> Text( stringResource( - R.string.balances_inbound_amount, - balance.pendingIncoming.toString(showSymbol = false), + R.string.balance_scope_exchange, + cleanExchange(balance.scopeInfo.url) ), - color = colorResource(R.color.green), ) - } - AnimatedVisibility(!balance.pendingOutgoing.isZero()) { - Text( + is Auditor -> Text( stringResource( - R.string.balances_outbound_amount, - balance.pendingOutgoing.toString(showSymbol = false) + R.string.balance_scope_auditor, + cleanExchange(balance.scopeInfo.url) ), - color = MaterialTheme.colorScheme.error, ) + + else -> {} } } - } + }, + ) + + if (!balance.pendingIncoming.isZero() || !balance.pendingOutgoing.isZero()) { + HorizontalDivider() + PendingComposable(balance, onPendingClick) } - ) + } } } @Composable +fun PendingComposable( + balance: BalanceItem, + onClick: () -> Unit, +) { + ListItem( + modifier = Modifier + .animateContentSize() + .clickable { onClick() }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + headlineContent = { + Column(modifier = Modifier.padding(vertical = 5.dp)) { + ProvideTextStyle(MaterialTheme.typography.bodyLarge) { + AnimatedVisibility(!balance.pendingIncoming.isZero()) { + Text( + stringResource( + R.string.balances_inbound_amount, + balance.pendingIncoming.toString(showSymbol = false), + ), + color = colorResource(R.color.green), + ) + } + + AnimatedVisibility(!balance.pendingOutgoing.isZero()) { + Text( + stringResource( + R.string.balances_outbound_amount, + balance.pendingOutgoing.toString(showSymbol = false) + ), + color = MaterialTheme.colorScheme.error, + ) + } + } + } + }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + contentDescription = null, + ) + } + ) +} + +@Composable fun EmptyBalancesComposable( innerPadding: PaddingValues, onGetDemoMoneyClicked: () -> Unit, @@ -244,6 +284,7 @@ fun BalancesComposablePreview() { innerPadding = PaddingValues(0.dp), state = BalanceState.Success(balances), txResult = TransactionsResult.Success(listOf()), + txStateFilter = null, selectedScope = null, selectedCurrencySpec = null, onGetDemoMoneyClicked = {}, @@ -251,6 +292,7 @@ fun BalancesComposablePreview() { onTransactionClicked = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, + onPendingClicked = {}, ) } } @@ -263,6 +305,7 @@ fun BalancesComposableEmptyPreview() { innerPadding = PaddingValues(0.dp), state = BalanceState.Success(listOf()), txResult = TransactionsResult.Success(listOf()), + txStateFilter = null, selectedScope = null, selectedCurrencySpec = null, onGetDemoMoneyClicked = {}, @@ -270,6 +313,7 @@ fun BalancesComposableEmptyPreview() { onTransactionClicked = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, + onPendingClicked = {}, ) } } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/compose/Banner.kt b/wallet/src/main/java/net/taler/wallet/compose/Banner.kt @@ -0,0 +1,56 @@ +/* + * This file is part of GNU Taler + * (C) 2025 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.ShapeDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun Banner( + modifier: Modifier = Modifier, + colors: CardColors = CardDefaults.cardColors(), + content: @Composable () -> Unit, +) { + Card( + modifier = modifier + .padding(horizontal = 9.dp) + .fillMaxWidth(), + colors = colors, + shape = ShapeDefaults.ExtraSmall, + ) { + Box(Modifier + .padding(10.dp) + .fillMaxWidth()) { + ProvideTextStyle(MaterialTheme.typography.labelLarge.copy( + textAlign = TextAlign.Center, + )) { + content() + } + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -23,7 +23,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonPrimitive import net.taler.wallet.TAG import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo @@ -39,6 +43,18 @@ sealed class TransactionsResult { data class Success(val transactions: List<Transaction>) : TransactionsResult() } +@Serializable +enum class TransactionStateFilter { + @SerialName("final") + Final, + + @SerialName("nonfinal") + Nonfinal, + + @SerialName("done") + Done, +} + class TransactionManager( private val api: WalletBackendApi, private val scope: CoroutineScope, @@ -48,20 +64,23 @@ class TransactionManager( private val mSelectedTransaction = MutableStateFlow<Transaction?>(null) private val mSelectedScope = MutableStateFlow<ScopeInfo?>(null) private val mSearchQuery = MutableStateFlow<String?>(null) + private val mStateFilter = MutableStateFlow<TransactionStateFilter?>(null) val selectedTransaction = mSelectedTransaction.asStateFlow() val selectedScope = mSelectedScope.asStateFlow() val searchQuery = mSearchQuery.asStateFlow() + val stateFilter = mStateFilter.asStateFlow() // This function must be called ONLY when scopeInfo / searchQuery change! // Use remember() {} in Compose to prevent multiple calls during recomposition fun transactionsFlow( scopeInfo: ScopeInfo? = null, searchQuery: String? = null, + stateFilter: TransactionStateFilter? = null, ): StateFlow<TransactionsResult> { loadTransactions() return if (scopeInfo != null) { - loadTransactions(scopeInfo, searchQuery) + loadTransactions(scopeInfo, searchQuery, stateFilter) mTransactions[scopeInfo]?.asStateFlow() ?: MutableStateFlow(TransactionsResult.None) } else { @@ -73,8 +92,9 @@ class TransactionManager( fun loadTransactions( scopeInfo: ScopeInfo? = null, searchQuery: String? = null, + stateFilter: TransactionStateFilter? = null, ) { - Log.d(TAG, "loadTransactions($scopeInfo, $searchQuery)") + Log.d(TAG, "loadTransactions($scopeInfo, $searchQuery, $stateFilter)") val s = scopeInfo ?: mSelectedScope.value ?: run { MutableStateFlow(TransactionsResult.None) return @@ -92,7 +112,7 @@ class TransactionManager( } // ...then fetch new ones - val res = getTransactions(s, searchQuery) + val res = getTransactions(s, searchQuery, filterByState = stateFilter) if (res is TransactionsResult.Success) { allTransactions[s] = res.transactions } @@ -102,10 +122,24 @@ class TransactionManager( } } - private suspend fun getTransactions(scope: ScopeInfo, searchQuery: String?): TransactionsResult { + private suspend fun getTransactions( + scope: ScopeInfo, + searchQuery: String?, + filterByState: TransactionStateFilter? = null, + offsetTransactionId: String? = null, + limit: Int? = null, + ): TransactionsResult { var result: TransactionsResult = TransactionsResult.None - api.request("getTransactions", Transactions.serializer()) { + api.request("getTransactionsV2", Transactions.serializer()) { if (searchQuery != null) put("search", searchQuery) + if (filterByState != null) put( + "filterByState", + BackendManager.json + .encodeToJsonElement(filterByState) + .jsonPrimitive.content, + ) + if (offsetTransactionId != null) put("offsetTransactionId", offsetTransactionId) + if (limit != null) put("limit", limit) put("scopeInfo", JSONObject(BackendManager.json.encodeToString(scope))) }.onError { error -> Log.e(TAG, "Error: getTransactions error result: $error") @@ -164,8 +198,12 @@ class TransactionManager( mSelectedTransaction.value = tx } - fun selectScope(scopeInfo: ScopeInfo?) = scope.launch { + fun selectScope( + scopeInfo: ScopeInfo?, + stateFilter: TransactionStateFilter? = null, + ) = scope.launch { mSelectedScope.value = scopeInfo + mStateFilter.value = stateFilter } fun setSearchQuery(searchQuery: String?) = scope.launch { diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt @@ -20,10 +20,8 @@ import android.content.res.Configuration import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ShapeDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -31,7 +29,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.common.Amount @@ -40,6 +37,7 @@ import net.taler.common.Timestamp import net.taler.common.toAbsoluteTime import net.taler.wallet.R import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.compose.Banner import net.taler.wallet.compose.TalerSurface import net.taler.wallet.transactions.TransactionMajorState.Aborted import net.taler.wallet.transactions.TransactionMajorState.Aborting @@ -107,23 +105,12 @@ fun TransactionStateComposable( else -> return } - Card( - modifier = modifier - .padding(horizontal = 9.dp) - .fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = cardColor, - ), - shape = ShapeDefaults.ExtraSmall, - ) { + Banner(colors = CardDefaults.cardColors(containerColor = cardColor)) { Text( modifier = Modifier - .padding(10.dp) .fillMaxWidth(), text = message, - style = MaterialTheme.typography.labelLarge, color = textColor, - textAlign = TextAlign.Center, ) } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt @@ -78,6 +78,7 @@ import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.balances.BalanceItem import net.taler.wallet.balances.ScopeInfo.Exchange import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.Banner import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.SelectionModeTopAppBar import net.taler.wallet.compose.TalerSurface @@ -100,6 +101,7 @@ import net.taler.wallet.transactions.TransactionMinorState.Repurchase import net.taler.wallet.transactions.TransactionsResult.Error import net.taler.wallet.transactions.TransactionsResult.None import net.taler.wallet.transactions.TransactionsResult.Success +import net.taler.wallet.transactions.TransactionStateFilter.* @Composable fun TransactionsComposable( @@ -107,6 +109,7 @@ fun TransactionsComposable( balance: BalanceItem, currencySpec: CurrencySpecification?, txResult: TransactionsResult, + txStateFilter: TransactionStateFilter?, onTransactionClick: (tx: Transaction) -> Unit, onTransactionsDelete: (txIds: List<String>) -> Unit, onShowBalancesClicked: () -> Unit, @@ -179,6 +182,21 @@ fun TransactionsComposable( ) } + when (txStateFilter) { + Nonfinal -> item { + Banner(Modifier.padding(bottom = 6.dp)) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.transactions_filter_nonfinal), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } + } + else -> {} + } + val placeholderPadding = PaddingValues(vertical = 50.dp) when (txResult) { is Success -> if (txResult.transactions.isEmpty()) item { @@ -511,6 +529,7 @@ fun TransactionsComposableDonePreview() { balance = previewBalance, currencySpec = null, txResult = Success(transactions), + txStateFilter = null, onTransactionClick = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, @@ -545,6 +564,7 @@ fun TransactionsComposablePendingPreview() { balance = previewBalance, currencySpec = null, txResult = Success(transactions), + txStateFilter = Nonfinal, onTransactionClick = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, @@ -561,6 +581,7 @@ fun TransactionsComposableEmptyPreview() { balance = previewBalance, currencySpec = null, txResult = Success(listOf()), + txStateFilter = null, onTransactionClick = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, @@ -577,6 +598,7 @@ fun TransactionsComposableLoadingPreview() { balance = previewBalance, currencySpec = null, txResult = None, + txStateFilter = null, onTransactionClick = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -115,8 +115,8 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="balances_empty_state">There is no digital cash in your wallet.\n\nYou can get demo money from the demo bank:\n\nhttps://bank.demo.taler.net</string> <string name="balances_empty_demo_url">https://bank.demo.taler.net</string> <string name="balances_empty_get_money">Get demo money</string> - <string name="balances_inbound_amount">+%1$s inbound</string> - <string name="balances_outbound_amount">-%1$s outbound</string> + <string name="balances_inbound_amount">+%1$s incoming</string> + <string name="balances_outbound_amount">-%1$s outgoing</string> <string name="balances_title">Balances</string> <!-- Transactions --> @@ -171,6 +171,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="transactions_fail">Abandon</string> <string name="transactions_fail_dialog_message">Are you sure you abandon this transaction? Funds still in transit WILL GET LOST.</string> <string name="transactions_fail_dialog_title">Abandon transaction</string> + <string name="transactions_filter_nonfinal">You are viewing pending transactions</string> <string name="transactions_receive_funds">Receive</string> <string name="transactions_resume">Resume</string> <string name="transactions_retry">Retry</string>