taler-android

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

TransactionsComposable.kt (21699B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2024 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.wallet.transactions
     18 
     19 import androidx.compose.animation.animateContentSize
     20 import androidx.compose.foundation.basicMarquee
     21 import androidx.compose.foundation.clickable
     22 import androidx.compose.foundation.combinedClickable
     23 import androidx.compose.foundation.layout.Box
     24 import androidx.compose.foundation.layout.Column
     25 import androidx.compose.foundation.layout.IntrinsicSize
     26 import androidx.compose.foundation.layout.PaddingValues
     27 import androidx.compose.foundation.layout.Row
     28 import androidx.compose.foundation.layout.consumeWindowInsets
     29 import androidx.compose.foundation.layout.defaultMinSize
     30 import androidx.compose.foundation.layout.fillMaxHeight
     31 import androidx.compose.foundation.layout.fillMaxSize
     32 import androidx.compose.foundation.layout.fillMaxWidth
     33 import androidx.compose.foundation.layout.height
     34 import androidx.compose.foundation.layout.padding
     35 import androidx.compose.foundation.lazy.LazyColumn
     36 import androidx.compose.foundation.lazy.items
     37 import androidx.compose.material.icons.Icons
     38 import androidx.compose.material.icons.filled.ArrowDropDown
     39 import androidx.compose.material.icons.rounded.CheckCircle
     40 import androidx.compose.material.icons.rounded.RadioButtonUnchecked
     41 import androidx.compose.material3.Badge
     42 import androidx.compose.material3.HorizontalDivider
     43 import androidx.compose.material3.Icon
     44 import androidx.compose.material3.ListItem
     45 import androidx.compose.material3.ListItemDefaults
     46 import androidx.compose.material3.MaterialTheme
     47 import androidx.compose.material3.OutlinedCard
     48 import androidx.compose.material3.ProvideTextStyle
     49 import androidx.compose.material3.Text
     50 import androidx.compose.runtime.Composable
     51 import androidx.compose.ui.Alignment
     52 import androidx.compose.ui.Alignment.Companion.Center
     53 import androidx.compose.ui.Modifier
     54 import androidx.compose.ui.graphics.Color
     55 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
     56 import androidx.compose.ui.platform.LocalContext
     57 import androidx.compose.ui.platform.LocalHapticFeedback
     58 import androidx.compose.ui.res.painterResource
     59 import androidx.compose.ui.res.stringResource
     60 import androidx.compose.ui.text.font.FontWeight
     61 import androidx.compose.ui.text.style.TextAlign
     62 import androidx.compose.ui.text.style.TextOverflow
     63 import androidx.compose.ui.tooling.preview.Preview
     64 import androidx.compose.ui.unit.dp
     65 import net.taler.common.Amount
     66 import net.taler.common.CurrencySpecification
     67 import net.taler.common.Timestamp
     68 import net.taler.lib.android.toRelativeTime
     69 import net.taler.wallet.R
     70 import net.taler.wallet.backend.TalerErrorCode
     71 import net.taler.wallet.backend.TalerErrorInfo
     72 import net.taler.wallet.balances.BalanceItem
     73 import net.taler.wallet.balances.ScopeInfo.Exchange
     74 import net.taler.wallet.cleanExchange
     75 import net.taler.wallet.compose.Banner
     76 import net.taler.wallet.compose.LoadingScreen
     77 import net.taler.wallet.compose.TalerSurface
     78 import net.taler.wallet.compose.cardPaddings
     79 import net.taler.wallet.main.ViewMode
     80 import net.taler.wallet.transactions.AmountType.Negative
     81 import net.taler.wallet.transactions.AmountType.Neutral
     82 import net.taler.wallet.transactions.AmountType.Positive
     83 import net.taler.wallet.transactions.TransactionAction.Abort
     84 import net.taler.wallet.transactions.TransactionAction.Retry
     85 import net.taler.wallet.transactions.TransactionAction.Suspend
     86 import net.taler.wallet.transactions.TransactionMajorState.Aborted
     87 import net.taler.wallet.transactions.TransactionMajorState.Aborting
     88 import net.taler.wallet.transactions.TransactionMajorState.Dialog
     89 import net.taler.wallet.transactions.TransactionMajorState.Done
     90 import net.taler.wallet.transactions.TransactionMajorState.Failed
     91 import net.taler.wallet.transactions.TransactionMajorState.Pending
     92 import net.taler.wallet.transactions.TransactionMinorState.BalanceKycRequired
     93 import net.taler.wallet.transactions.TransactionMinorState.BankConfirmTransfer
     94 import net.taler.wallet.transactions.TransactionMinorState.KycAuthRequired
     95 import net.taler.wallet.transactions.TransactionMinorState.KycInit
     96 import net.taler.wallet.transactions.TransactionMinorState.KycRequired
     97 import net.taler.wallet.transactions.TransactionMinorState.Repurchase
     98 import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal
     99 import net.taler.wallet.transactions.TransactionsResult.Error
    100 import net.taler.wallet.transactions.TransactionsResult.None
    101 import net.taler.wallet.transactions.TransactionsResult.Success
    102 import net.taler.wallet.ui.theme.TalerTheme
    103 
    104 @Composable
    105 fun TransactionsComposable(
    106     innerPadding: PaddingValues,
    107     viewMode: ViewMode.Transactions,
    108     balance: BalanceItem,
    109     txResult: TransactionsResult,
    110     onTransactionClick: (tx: Transaction) -> Unit,
    111     onShowBalancesClicked: () -> Unit,
    112     selectionMode: Boolean,
    113     selectedItems: MutableList<String>,
    114     onToggleSelection: (String) -> Unit,
    115 ) {
    116     Column(Modifier.fillMaxSize()) {
    117         LazyColumn(
    118             Modifier
    119                 .weight(1f)
    120                 .consumeWindowInsets(innerPadding),
    121             contentPadding = innerPadding,
    122         ) {
    123             item {
    124                 TransactionsHeader(
    125                     balance = balance,
    126                     onShowBalancesClicked = onShowBalancesClicked,
    127                 )
    128             }
    129 
    130             when (viewMode.stateFilter) {
    131                 Nonfinal -> item {
    132                     Banner(Modifier.padding(bottom = 6.dp)) {
    133                         Text(
    134                             modifier = Modifier
    135                                 .fillMaxWidth(),
    136                             text = stringResource(R.string.transactions_filter_nonfinal),
    137                             style = MaterialTheme.typography.labelLarge,
    138                             textAlign = TextAlign.Center,
    139                         )
    140                     }
    141                 }
    142                 else -> {}
    143             }
    144 
    145             val placeholderPadding = PaddingValues(vertical = 50.dp)
    146             when (txResult) {
    147                 is Success -> if (txResult.transactions.isEmpty()) item {
    148                     Box(Modifier.padding(placeholderPadding)) {
    149                         EmptyTransactionsComposable()
    150                     }
    151                 } else {
    152                     items(txResult.transactions, key = { it.transactionId }) { tx ->
    153                         val isSelected = selectedItems.contains(tx.transactionId)
    154 
    155                         TransactionRow(
    156                             tx, balance.available.spec,
    157                             isSelected = isSelected,
    158                             selectionMode = selectionMode,
    159                             onTransactionClick = {
    160                                 if (selectionMode) {
    161                                     onToggleSelection(tx.transactionId)
    162                                 } else {
    163                                     onTransactionClick(tx)
    164                                 }
    165                             },
    166                             onTransactionSelect = {
    167                                 onToggleSelection(tx.transactionId)
    168                             },
    169                         )
    170                     }
    171                 }
    172 
    173                 is None -> item {
    174                     Box(Modifier.padding(placeholderPadding)) {
    175                         LoadingScreen()
    176                     }
    177                 }
    178                 is Error -> item {
    179                     Box(Modifier.padding(placeholderPadding)) {
    180                         ErrorTransactionsComposable(txResult.error)
    181                     }
    182                 }
    183             }
    184         }
    185     }
    186 }
    187 
    188 @Composable
    189 fun EmptyTransactionsComposable() {
    190     Box(
    191         modifier = Modifier.fillMaxSize(),
    192         contentAlignment = Center,
    193     ) {
    194         Text(
    195             stringResource(R.string.transactions_empty),
    196             textAlign = TextAlign.Center,
    197             style = MaterialTheme.typography.bodyMedium,
    198         )
    199     }
    200 }
    201 
    202 @Composable
    203 fun ErrorTransactionsComposable(error: TalerErrorInfo) {
    204     Text(
    205         text = stringResource(R.string.transactions_error, error.userFacingMsg),
    206         style = MaterialTheme.typography.titleLarge,
    207         color = MaterialTheme.colorScheme.error,
    208     )
    209 }
    210 
    211 @Composable
    212 fun TransactionsHeader(
    213     balance: BalanceItem,
    214     onShowBalancesClicked: () -> Unit,
    215 ) {
    216     Row(
    217         modifier = Modifier.fillMaxWidth(),
    218         verticalAlignment = Alignment.CenterVertically,
    219     ) {
    220         OutlinedCard(
    221             Modifier
    222                 .weight(1f)
    223                 .cardPaddings()
    224                 .clickable { onShowBalancesClicked() },
    225         ) {
    226             ListItem(
    227                 modifier = Modifier.animateContentSize(),
    228                 headlineContent = {
    229                     Text(
    230                         getHeaderCurrency(balance, balance.available.spec),
    231                         modifier = Modifier.weight(1f, fill = false),
    232                         style = MaterialTheme.typography.titleMedium,
    233                         overflow = TextOverflow.Ellipsis,
    234                         maxLines = 1,
    235                     )
    236                 },
    237                 supportingContent = {
    238                     if (balance.scopeInfo is Exchange) {
    239                         Text(
    240                             cleanExchange(balance.scopeInfo.url),
    241                             modifier = Modifier
    242                                 .padding(top = 3.dp)
    243                                 .basicMarquee(),
    244                             style = MaterialTheme.typography.bodySmall,
    245                         )
    246                     }
    247                 },
    248                 trailingContent = {
    249                     Icon(Icons.Default.ArrowDropDown, contentDescription = null)
    250                 }
    251             )
    252         }
    253 
    254         Column(
    255             modifier = Modifier.padding(16.dp),
    256             horizontalAlignment = Alignment.End,
    257         ) {
    258             Text(
    259                 stringResource(R.string.transactions_balance),
    260                 modifier = Modifier.padding(bottom = 6.dp),
    261                 style = MaterialTheme.typography.bodySmall,
    262             )
    263 
    264             Text(
    265                 balance.available.toString(showSymbol = false),
    266                 style = MaterialTheme.typography.titleLarge,
    267                 fontWeight = FontWeight.Bold,
    268             )
    269         }
    270     }
    271 }
    272 
    273 @Composable
    274 fun TransactionRow(
    275     tx: Transaction,
    276     spec: CurrencySpecification?,
    277     isSelected: Boolean,
    278     selectionMode: Boolean,
    279     onTransactionClick: () -> Unit,
    280     onTransactionSelect: () -> Unit,
    281 ) {
    282     val haptic = LocalHapticFeedback.current
    283 
    284     Column {
    285         ListItem(
    286             modifier = Modifier
    287                 .height(IntrinsicSize.Min)
    288                 .defaultMinSize(minHeight = 80.dp)
    289                 .combinedClickable(
    290                     onClick = onTransactionClick,
    291                     onLongClick = {
    292                         haptic.performHapticFeedback(HapticFeedbackType.LongPress)
    293                         onTransactionSelect()
    294                     },
    295                 ),
    296             trailingContent = {
    297                 Box(
    298                     modifier = Modifier
    299                         .fillMaxHeight()
    300                         .padding(8.dp),
    301                     contentAlignment = Center,
    302                 ) {
    303                     TransactionAmountInfo(tx, spec)
    304                 }
    305             },
    306             leadingContent = {
    307                 Box(
    308                     modifier = Modifier
    309                         .fillMaxHeight()
    310                         .padding(8.dp),
    311                     contentAlignment = Center,
    312                 ) {
    313                     if (!selectionMode) {
    314                         Icon(painterResource(tx.icon), contentDescription = null)
    315                     } else if (isSelected) {
    316                         Icon(
    317                             Icons.Rounded.CheckCircle,
    318                             contentDescription = null,
    319                             tint = MaterialTheme.colorScheme.primary,
    320                         )
    321                     } else {
    322                         Icon(
    323                             Icons.Rounded.RadioButtonUnchecked,
    324                             contentDescription = null,
    325                             tint = MaterialTheme.colorScheme.outline,
    326                         )
    327                     }
    328                 }
    329             },
    330             headlineContent = {
    331                 Column(Modifier.padding(vertical = 8.dp)) {
    332                     Text(
    333                         text = tx.timestamp.ms.toRelativeTime(LocalContext.current).toString(),
    334                         style = MaterialTheme.typography.labelSmall,
    335                         color = MaterialTheme.colorScheme.onSurfaceVariant,
    336                     )
    337                     Text(
    338                         tx.getTitle(),
    339                         modifier = Modifier.padding(vertical = 3.dp),
    340                         style = MaterialTheme.typography.titleMedium,
    341                     )
    342                     ProvideTextStyle(
    343                         MaterialTheme.typography.bodyMedium.copy(
    344                             color = MaterialTheme.colorScheme.onSurfaceVariant
    345                         )
    346                     ) {
    347                         TransactionExtraInfo(tx)
    348                     }
    349                 }
    350             },
    351             colors = ListItemDefaults.colors(
    352                 containerColor = if (isSelected) {
    353                     MaterialTheme.colorScheme.secondaryContainer
    354                 } else {
    355                     ListItemDefaults.containerColor
    356                 }
    357             )
    358         )
    359 
    360         HorizontalDivider()
    361     }
    362 }
    363 
    364 @Composable
    365 fun TransactionAmountInfo(
    366     tx: Transaction,
    367     spec: CurrencySpecification?,
    368 ) {
    369     Column(horizontalAlignment = Alignment.End) {
    370         ProvideTextStyle(MaterialTheme.typography.titleLarge) {
    371             val amountStr = tx.amountEffective.withSpec(spec).toString(showSymbol = false)
    372             if (tx.amountEffective.isZero()) {
    373                 Text(amountStr)
    374             } else when (tx.amountType) {
    375                 Positive -> Text(
    376                     stringResource(R.string.amount_positive, amountStr),
    377                     color = if (tx.txState.major == Pending || tx.txState.major == Dialog)
    378                         Color.Unspecified else TalerTheme.extraColors.success,
    379                 )
    380                 Negative -> Text(
    381                     stringResource(R.string.amount_negative, amountStr),
    382                     color = if (tx.txState.major == Pending || tx.txState.major == Dialog)
    383                         Color.Unspecified else MaterialTheme.colorScheme.error,
    384                 )
    385                 Neutral -> Text(amountStr)
    386             }
    387         }
    388 
    389         if (tx.txState.major == Pending || tx.txState.major == Dialog) {
    390             Badge(
    391                 Modifier.padding(top = 3.dp),
    392                 containerColor = MaterialTheme.colorScheme.secondary,
    393             ) {
    394                 Text(stringResource(R.string.transaction_pending))
    395             }
    396         }
    397     }
    398 }
    399 
    400 @Composable
    401 fun TransactionExtraInfo(tx: Transaction) {
    402     when {
    403         tx.txState.major == Aborted -> Text(
    404             stringResource(R.string.payment_aborted),
    405             color = MaterialTheme.colorScheme.error,
    406         )
    407 
    408         tx.txState.minor == Repurchase -> Text(
    409             stringResource(R.string.payment_repurchase),
    410         )
    411 
    412         tx.txState.major == Failed -> Text(
    413             stringResource(R.string.payment_failed),
    414             color = MaterialTheme.colorScheme.error,
    415         )
    416 
    417         tx.txState.major == Aborting -> Text(
    418             stringResource(R.string.payment_aborting),
    419             color = MaterialTheme.colorScheme.error,
    420         )
    421 
    422         tx.txState.major == Pending -> when(tx.txState.minor) {
    423             BankConfirmTransfer -> Text(stringResource(R.string.withdraw_waiting_confirm))
    424             KycInit -> Text(stringResource(R.string.transaction_preparing_kyc))
    425             KycRequired,
    426             KycAuthRequired,
    427             BalanceKycRequired  -> Text(stringResource(R.string.transactions_required_kyc))
    428             else -> {}
    429         }
    430 
    431         tx.txState.major == Dialog -> {}
    432 
    433         tx is TransactionWithdrawal && !tx.confirmed -> Text(stringResource(R.string.withdraw_waiting_confirm))
    434         tx is TransactionPeerPushCredit && tx.info.summary != null -> Text(tx.info.summary)
    435         tx is TransactionPeerPushDebit && tx.info.summary != null -> Text(tx.info.summary)
    436         tx is TransactionPeerPullCredit && tx.info.summary != null -> Text(tx.info.summary)
    437         tx is TransactionPeerPullDebit && tx.info.summary != null -> Text(tx.info.summary)
    438     }
    439 }
    440 
    441 @Composable
    442 private fun getHeaderCurrency(
    443     balance: BalanceItem,
    444     spec: CurrencySpecification?,
    445 ) = if (spec != null) {
    446     if (spec.symbol != null && spec.name != spec.symbol) {
    447         // Name (symbol)
    448         stringResource(R.string.transactions_currency, spec.name, spec.symbol!!)
    449     } else if (spec.name != balance.currency) {
    450         // Name (currency string)
    451         stringResource(R.string.transactions_currency, spec.name, balance.currency)
    452     } else balance.currency
    453 } else balance.currency
    454 
    455 private val previewBalance = BalanceItem(
    456     scopeInfo = Exchange("MXN", "https://exchange.taler.banxico.org.mx"),
    457     available = Amount.fromJSONString("MXN:5.50"),
    458     pendingIncoming = Amount.fromJSONString("MXN:1.40"),
    459     pendingOutgoing = Amount.fromJSONString("MXN:0"),
    460     disablePeerPayments = false,
    461 )
    462 
    463 @Preview
    464 @Composable
    465 fun TransactionsComposableDonePreview() {
    466     val t = TransactionWithdrawal(
    467         transactionId = "transactionId",
    468         timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
    469         txState = TransactionState(Done),
    470         txActions = listOf(Retry, Suspend, Abort),
    471         exchangeBaseUrl = "https://exchange.demo.taler.net/",
    472         withdrawalDetails = WithdrawalDetails.TalerBankIntegrationApi(false),
    473         amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
    474         amountEffective = Amount.fromString("TESTKUDOS", "42.1337"),
    475         error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED),
    476         scopes = listOf(Exchange(
    477             currency = "TESTKUDOS",
    478             url = "exchange.test.taler.net",
    479         ))
    480     )
    481 
    482     val transactions = listOf(t)
    483 
    484     TalerSurface {
    485         TransactionsComposable(
    486             innerPadding = PaddingValues(0.dp),
    487             balance = previewBalance,
    488             viewMode = ViewMode.Transactions(previewBalance.scopeInfo),
    489             txResult = Success(transactions),
    490             onTransactionClick = {},
    491             onShowBalancesClicked = {},
    492             selectionMode = false,
    493             selectedItems = mutableListOf(),
    494             onToggleSelection = {},
    495         )
    496     }
    497 }
    498 
    499 @Preview
    500 @Composable
    501 fun TransactionsComposablePendingPreview() {
    502     val t = TransactionWithdrawal(
    503         transactionId = "transactionId",
    504         timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
    505         txState = TransactionState(Pending),
    506         txActions = listOf(Retry, Suspend, Abort),
    507         exchangeBaseUrl = "https://exchange.demo.taler.net/",
    508         withdrawalDetails = WithdrawalDetails.TalerBankIntegrationApi(false),
    509         amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
    510         amountEffective = Amount.fromString("TESTKUDOS", "42.1337"),
    511         error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED),
    512         scopes = listOf(Exchange(
    513             currency = "TESTKUDOS",
    514             url = "exchange.test.taler.net",
    515         ))
    516     )
    517 
    518     val transactions = listOf(t)
    519 
    520     TalerSurface {
    521         TransactionsComposable(
    522             innerPadding = PaddingValues(0.dp),
    523             balance = previewBalance,
    524             viewMode = ViewMode.Transactions(previewBalance.scopeInfo),
    525             txResult = Success(transactions),
    526             onTransactionClick = {},
    527             onShowBalancesClicked = {},
    528             selectionMode = false,
    529             selectedItems = mutableListOf(),
    530             onToggleSelection = {},
    531         )
    532     }
    533 }
    534 
    535 @Preview
    536 @Composable
    537 fun TransactionsComposableEmptyPreview() {
    538     TalerSurface {
    539         TransactionsComposable(
    540             innerPadding = PaddingValues(0.dp),
    541             balance = previewBalance,
    542             viewMode = ViewMode.Transactions(previewBalance.scopeInfo),
    543             txResult = Success(listOf()),
    544             onTransactionClick = {},
    545             onShowBalancesClicked = {},
    546             selectionMode = false,
    547             selectedItems = mutableListOf(),
    548             onToggleSelection = {},
    549         )
    550     }
    551 }
    552 
    553 @Preview
    554 @Composable
    555 fun TransactionsComposableLoadingPreview() {
    556     TalerSurface {
    557         TransactionsComposable(
    558             innerPadding = PaddingValues(0.dp),
    559             balance = previewBalance,
    560             viewMode = ViewMode.Transactions(previewBalance.scopeInfo),
    561             txResult = None,
    562             onTransactionClick = {},
    563             onShowBalancesClicked = {},
    564             selectionMode = false,
    565             selectedItems = mutableListOf(),
    566             onToggleSelection = {},
    567         )
    568     }
    569 }