taler-android

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

TransactionDetailScreen.kt (23081B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 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 android.content.Context
     20 import android.util.Log
     21 import androidx.compose.foundation.basicMarquee
     22 import androidx.compose.foundation.layout.Box
     23 import androidx.compose.foundation.layout.Column
     24 import androidx.compose.foundation.layout.Row
     25 import androidx.compose.foundation.layout.Spacer
     26 import androidx.compose.foundation.layout.fillMaxSize
     27 import androidx.compose.foundation.layout.fillMaxWidth
     28 import androidx.compose.foundation.layout.padding
     29 import androidx.compose.foundation.layout.size
     30 import androidx.compose.foundation.rememberScrollState
     31 import androidx.compose.foundation.verticalScroll
     32 import androidx.compose.material.icons.Icons
     33 import androidx.compose.material.icons.filled.ContentCopy
     34 import androidx.compose.material3.ButtonDefaults
     35 import androidx.compose.material3.Icon
     36 import androidx.compose.material3.MaterialTheme
     37 import androidx.compose.material3.Surface
     38 import androidx.compose.material3.Text
     39 import androidx.compose.material3.TextButton
     40 import androidx.compose.runtime.Composable
     41 import androidx.compose.runtime.DisposableEffect
     42 import androidx.compose.runtime.LaunchedEffect
     43 import androidx.compose.runtime.getValue
     44 import androidx.compose.runtime.livedata.observeAsState
     45 import androidx.compose.runtime.mutableStateOf
     46 import androidx.compose.runtime.remember
     47 import androidx.compose.runtime.setValue
     48 import androidx.compose.ui.Alignment
     49 import androidx.compose.ui.Modifier
     50 import androidx.compose.ui.graphics.Color
     51 import androidx.compose.ui.platform.LocalContext
     52 import androidx.compose.ui.res.stringResource
     53 import androidx.compose.ui.text.style.TextAlign
     54 import androidx.compose.ui.tooling.preview.Preview
     55 import androidx.compose.ui.unit.dp
     56 import androidx.compose.ui.unit.sp
     57 import net.taler.common.Amount
     58 import net.taler.common.CurrencySpecification
     59 import net.taler.common.Timestamp
     60 import net.taler.lib.android.copyToClipBoard
     61 import net.taler.lib.android.toAbsoluteTime
     62 import net.taler.wallet.BottomInsetsSpacer
     63 import net.taler.wallet.NavigateCallback
     64 import net.taler.wallet.R
     65 import net.taler.wallet.WalletDestination
     66 import net.taler.wallet.backend.TalerErrorCode
     67 import net.taler.wallet.backend.TalerErrorInfo
     68 import net.taler.wallet.balances.ScopeInfo
     69 import net.taler.wallet.compose.GlobalScaffold
     70 import net.taler.wallet.compose.collectAsStateLifecycleAware
     71 import net.taler.wallet.deposit.TransactionDepositComposable
     72 import net.taler.wallet.launchInAppBrowser
     73 import net.taler.wallet.main.MainViewModel
     74 import net.taler.wallet.main.TAG
     75 import net.taler.wallet.payment.PayStatus
     76 import net.taler.wallet.payment.TransactionPaymentComposable
     77 import net.taler.wallet.peer.TransactionPeerPullCreditComposable
     78 import net.taler.wallet.peer.TransactionPeerPullDebitComposable
     79 import net.taler.wallet.peer.TransactionPeerPushCreditComposable
     80 import net.taler.wallet.peer.TransactionPeerPushDebitComposable
     81 import net.taler.wallet.refund.TransactionRefundComposable
     82 import net.taler.wallet.ui.theme.TalerTheme
     83 import net.taler.wallet.withdraw.TransactionWithdrawalComposable
     84 
     85 @Composable
     86 fun TransactionDetailScreen(
     87     model: MainViewModel,
     88     destination: WalletDestination,
     89     onNavigate: NavigateCallback,
     90     onNavigateBack: () -> Unit,
     91 ) {
     92     val transactionManager = model.transactionManager
     93     val exchangeManager = model.exchangeManager
     94     val withdrawManager = model.withdrawManager
     95     val devMode by model.devMode.observeAsState(false)
     96     val context = LocalContext.current
     97     var keepSelectedTx: Boolean by remember { mutableStateOf(false) }
     98 
     99     DisposableEffect(keepSelectedTx) {
    100         onDispose {
    101             if (!keepSelectedTx)  {
    102                 transactionManager.selectTransaction(null)
    103             }
    104         }
    105     }
    106 
    107     GlobalScaffold(
    108         model = model,
    109         title = {
    110             val title = when (destination) {
    111                 is WalletDestination.TransactionPayment -> stringResource(R.string.transaction_order)
    112                 is WalletDestination.TransactionWithdrawal -> stringResource(R.string.withdraw_title)
    113                 is WalletDestination.TransactionDeposit -> stringResource(R.string.transaction_deposit)
    114                 is WalletDestination.TransactionRefund -> stringResource(R.string.transaction_refund)
    115                 is WalletDestination.TransactionRefresh -> stringResource(R.string.transaction_refresh)
    116                 is WalletDestination.TransactionPeer -> stringResource(R.string.transaction_peer_push_debit) // Approximation
    117                 is WalletDestination.TransactionLoss -> stringResource(R.string.transaction_denom_loss)
    118                 else -> stringResource(R.string.transactions_detail_title)
    119             }
    120             Text(title)
    121         },
    122         onNavigateBack = onNavigateBack,
    123     ) { paddingValues ->
    124         val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware()
    125         val modifier = Modifier
    126             .fillMaxSize()
    127             .padding(paddingValues)
    128         Box(Modifier.fillMaxSize()) {
    129             when (destination) {
    130                 is WalletDestination.TransactionPayment -> {
    131                     (t as? TransactionPayment)?.let { tx ->
    132                         LaunchedEffect(tx.txState) {
    133                             if (tx.txState.major == TransactionMajorState.Dialog) {
    134                                 model.paymentManager.getPaymentChoices(tx.transactionId) {}
    135                             }
    136                         }
    137 
    138                         TransactionPaymentComposable(
    139                             t = tx,
    140                             payStatus = model.paymentManager.payStatus
    141                                 .observeAsState(PayStatus.None).value,
    142                             devMode = devMode,
    143                             spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes),
    144                             modifier = modifier,
    145                             onFulfill = { url ->
    146                                 launchInAppBrowser(context, url)
    147                             },
    148                             onTransition = { action ->
    149                                 handleTransactionAction(tx, action, model, onNavigateBack)
    150                             },
    151                             onConfirmPay = { choiceIndex, useDonau ->
    152                                 model.paymentManager.confirmPay(
    153                                     transactionId = tx.transactionId,
    154                                     choiceIndex = choiceIndex,
    155                                     useDonau = useDonau,
    156                                 )
    157                             },
    158                             onSetupDonau = { donauBaseUrl ->
    159                                 onNavigate(WalletDestination.SetDonau(donauBaseUrl), false)
    160                             },
    161                             checkDonauForChoice = { choice ->
    162                                 model.paymentManager.checkDonauForChoice(choice)
    163                             }
    164                         )
    165                     }
    166                 }
    167 
    168                 is WalletDestination.TransactionWithdrawal -> {
    169                     (t as? TransactionWithdrawal)?.let { tx ->
    170                         val qrCode = remember(tx) {
    171                             (tx.withdrawalDetails as? WithdrawalDetails.ManualTransfer)?.let { details ->
    172                                 if (details.exchangeCreditAccountDetails?.size == 1) {
    173                                     val account0 = details.exchangeCreditAccountDetails[0]
    174                                     val qrCodes = withdrawManager.getQrCodesForPayto(account0.paytoUri)
    175                                     if (qrCodes.size == 1) qrCodes[0]
    176                                     else null
    177                                 } else null
    178                             }
    179                         }
    180 
    181                         TransactionWithdrawalComposable(
    182                             modifier = modifier,
    183                             t = tx,
    184                             devMode = devMode,
    185                             spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes),
    186                             qrCode = qrCode,
    187                             onConfirmKyc = { url ->
    188                                 launchInAppBrowser(context, url)
    189                             },
    190                             onConfirmBank = {
    191                                 if (tx.withdrawalDetails is WithdrawalDetails.TalerBankIntegrationApi) {
    192                                     tx.withdrawalDetails.bankConfirmationUrl?.let { url ->
    193                                         launchInAppBrowser(context, url)
    194                                     }
    195                                 }
    196                             },
    197                             onConfirmManual = {
    198                                 keepSelectedTx = true
    199                                 onNavigate(WalletDestination.WireTransferDetails(false), false)
    200                             },
    201                             onShowQrCodes = {
    202                                 keepSelectedTx = true
    203                                 onNavigate(WalletDestination.WireTransferDetails(true), false)
    204                             },
    205                             onTransition = { action ->
    206                                 handleTransactionAction(tx, action, model, onNavigateBack)
    207                             }
    208                         )
    209                     }
    210                 }
    211 
    212                 is WalletDestination.TransactionDeposit -> {
    213                     (t as? TransactionDeposit)?.let { tx ->
    214                         TransactionDepositComposable(
    215                             modifier = modifier,
    216                             t = tx,
    217                             devMode = devMode,
    218                             spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes),
    219                             onWireTransfer = {
    220                                 keepSelectedTx = true
    221                                 onNavigate(
    222                                     WalletDestination.WireTransferDetails(false),
    223                                     false
    224                                 )
    225                             },
    226                             onShowQrCodes = {
    227                                 keepSelectedTx = true
    228                                 onNavigate(
    229                                     WalletDestination.WireTransferDetails(true),
    230                                     false
    231                                 )
    232                             },
    233                             onTransition = {
    234                                 handleTransactionAction(tx, it, model, onNavigateBack)
    235                             },
    236                         )
    237                     }
    238                 }
    239 
    240                 is WalletDestination.TransactionRefund -> {
    241                     (t as? TransactionRefund)?.let { tx ->
    242                         TransactionRefundComposable(
    243                             modifier = modifier,
    244                             t = tx,
    245                             devMode = devMode,
    246                             spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes),
    247                             onTransition = {
    248                                 handleTransactionAction(tx, it, model, onNavigateBack)
    249                             },
    250                         )
    251                     }
    252                 }
    253 
    254                 is WalletDestination.TransactionRefresh -> {
    255                     (t as? TransactionRefresh)?.let { tx ->
    256                         TransactionRefreshComposable(
    257                             modifier = modifier,
    258                             t = tx,
    259                             devMode = devMode,
    260                             spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes),
    261                         ) {
    262                             handleTransactionAction(tx, it, model, onNavigateBack)
    263                         }
    264                     }
    265                 }
    266 
    267                 is WalletDestination.TransactionPeer -> {
    268                     val tx = t
    269                     if (tx != null) {
    270                         TransactionPeerComposable(
    271                             modifier = modifier,
    272                             t = tx,
    273                             devMode = devMode,
    274                             spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes),
    275                             onConfirmKyc = { url -> launchInAppBrowser(context, url) }
    276                         ) {
    277                             handleTransactionAction(tx, it, model, onNavigateBack)
    278                         }
    279                     }
    280                 }
    281 
    282                 is WalletDestination.TransactionLoss -> {
    283                     (t as? TransactionDenomLoss)?.let { tx ->
    284                         TransitionLossComposable(
    285                             modifier = modifier,
    286                             t = tx,
    287                             devMode = devMode,
    288                             spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes)
    289                         ) {
    290                             handleTransactionAction(tx, it, model, onNavigateBack)
    291                         }
    292                     }
    293                 }
    294 
    295                 is WalletDestination.TransactionDummy -> {
    296                     (t as? DummyTransaction)?.let { tx ->
    297                         TransactionDummyComposable(
    298                             modifier = modifier,
    299                             t = tx,
    300                         )
    301                     }
    302                 }
    303 
    304                 else -> {}
    305             }
    306         }
    307     }
    308 }
    309 
    310 @Composable
    311 fun TransactionRefreshComposable(
    312     t: TransactionRefresh,
    313     devMode: Boolean,
    314     spec: CurrencySpecification?,
    315     modifier: Modifier = Modifier,
    316     onTransition: (t: TransactionAction) -> Unit,
    317 ) {
    318     val scrollState = rememberScrollState()
    319     Column(
    320         modifier = modifier
    321             .fillMaxWidth()
    322             .verticalScroll(scrollState),
    323         horizontalAlignment = Alignment.CenterHorizontally,
    324     ) {
    325         TransactionStateComposable(state = t.txState)
    326 
    327         Text(
    328             modifier = Modifier.padding(16.dp),
    329             text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(),
    330             style = MaterialTheme.typography.bodyLarge,
    331         )
    332 
    333         TransactionAmountComposable(
    334             label = stringResource(id = R.string.amount_fee),
    335             amount = t.amountEffective.withSpec(spec),
    336             amountType = AmountType.Negative,
    337         )
    338 
    339         TransitionsComposable(t, devMode, onTransition)
    340         t.error?.let { error ->
    341             if (devMode) {
    342                 ErrorTransactionButton(error = error)
    343             }
    344         }
    345 
    346         BottomInsetsSpacer()
    347     }
    348 }
    349 
    350 @Composable
    351 fun TransactionPeerComposable(
    352     t: Transaction,
    353     devMode: Boolean,
    354     spec: CurrencySpecification?,
    355     modifier: Modifier = Modifier,
    356     onConfirmKyc: (url: String) -> Unit,
    357     onTransition: (t: TransactionAction) -> Unit,
    358 ) {
    359     val scrollState = rememberScrollState()
    360     Column(
    361         modifier = modifier
    362             .fillMaxWidth()
    363             .verticalScroll(scrollState),
    364         horizontalAlignment = Alignment.CenterHorizontally,
    365     ) {
    366         TransactionStateComposable(state = t.txState)
    367 
    368         Text(
    369             modifier = Modifier.padding(16.dp),
    370             text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(),
    371             style = MaterialTheme.typography.bodyLarge,
    372         )
    373 
    374         when (t) {
    375             is TransactionPeerPullCredit -> TransactionPeerPullCreditComposable(t, spec, onConfirmKyc)
    376             is TransactionPeerPushCredit -> TransactionPeerPushCreditComposable(t, spec, onConfirmKyc)
    377             is TransactionPeerPullDebit -> TransactionPeerPullDebitComposable(t, spec)
    378             is TransactionPeerPushDebit -> TransactionPeerPushDebitComposable(t, spec)
    379             else -> {}
    380         }
    381 
    382         TransitionsComposable(t, devMode, onTransition)
    383 
    384         t.error?.let { error ->
    385             if (devMode) {
    386                 ErrorTransactionButton(error = error)
    387             }
    388         }
    389 
    390         BottomInsetsSpacer()
    391     }
    392 }
    393 
    394 @Composable
    395 fun TransitionLossComposable(
    396     t: TransactionDenomLoss,
    397     devMode: Boolean,
    398     spec: CurrencySpecification?,
    399     modifier: Modifier = Modifier,
    400     onTransition: (t: TransactionAction) -> Unit,
    401 ) {
    402     val scrollState = rememberScrollState()
    403 
    404     Column(
    405         modifier = modifier
    406             .fillMaxWidth()
    407             .verticalScroll(scrollState),
    408         horizontalAlignment = Alignment.CenterHorizontally,
    409     ) {
    410         TransactionStateComposable(state = t.txState)
    411 
    412         Text(
    413             modifier = Modifier.padding(16.dp),
    414             text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(),
    415             style = MaterialTheme.typography.bodyLarge,
    416         )
    417 
    418         TransactionAmountComposable(
    419             label = stringResource(id = R.string.amount_lost),
    420             amount = t.amountEffective.withSpec(spec),
    421             amountType = AmountType.Negative,
    422         )
    423 
    424         TransactionInfoComposable(
    425             label = stringResource(id = R.string.loss_reason),
    426             info = stringResource(
    427                 when(t.lossEventType) {
    428                     LossEventType.DenomExpired -> R.string.loss_reason_expired
    429                     LossEventType.DenomVanished -> R.string.loss_reason_vanished
    430                     LossEventType.DenomUnoffered -> R.string.loss_reason_unoffered
    431                 }
    432             )
    433         )
    434 
    435         TransitionsComposable(t, devMode, onTransition)
    436 
    437         t.error?.let { error ->
    438             if (devMode) {
    439                 ErrorTransactionButton(error = error)
    440             }
    441         }
    442 
    443         BottomInsetsSpacer()
    444     }
    445 }
    446 
    447 @Composable
    448 fun TransactionDummyComposable(
    449     t: DummyTransaction,
    450     modifier: Modifier = Modifier,
    451 ) {
    452     val scrollState = rememberScrollState()
    453     Column(
    454         modifier = modifier
    455             .fillMaxWidth()
    456             .padding(16.dp)
    457             .verticalScroll(scrollState),
    458         horizontalAlignment = Alignment.CenterHorizontally,
    459     ) {
    460         ErrorTransactionButton(error = t.error)
    461         BottomInsetsSpacer()
    462     }
    463 }
    464 
    465 @Composable
    466 fun TransactionAmountComposable(
    467     label: String,
    468     amount: Amount,
    469     amountType: AmountType,
    470     context: Context? = null,
    471     copy: Boolean = false,
    472 ) {
    473     Text(
    474         modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
    475         text = label,
    476         style = MaterialTheme.typography.bodyMedium,
    477     )
    478     Row(
    479         verticalAlignment = Alignment.CenterVertically,
    480     ) {
    481         Text(
    482             modifier = Modifier
    483                 .padding(
    484                     top = 8.dp,
    485                     start = 16.dp,
    486                     end = 16.dp,
    487                     bottom = 16.dp,
    488                 ).weight(1f),
    489             text = amount.toString(negative = amountType == AmountType.Negative),
    490             textAlign = TextAlign.Center,
    491             fontSize = 24.sp,
    492             color = when (amountType) {
    493                 AmountType.Positive -> TalerTheme.extraColors.success
    494                 AmountType.Negative -> MaterialTheme.colorScheme.error
    495                 AmountType.Neutral -> Color.Unspecified
    496             },
    497         )
    498 
    499         if (copy) {
    500             TextButton(
    501                 onClick = { context?.let {
    502                     copyToClipBoard(context, label, amount.toString(showSymbol = false))
    503                 }  },
    504             ) {
    505                 Icon(
    506                     Icons.Default.ContentCopy,
    507                     contentDescription = null,
    508                     modifier = Modifier.size(ButtonDefaults.IconSize),
    509                 )
    510                 Spacer(Modifier.size(ButtonDefaults.IconSpacing))
    511                 Text(stringResource(R.string.copy))
    512             }
    513         }
    514     }
    515 }
    516 
    517 @Composable
    518 fun TransactionInfoComposable(
    519     label: String,
    520     info: String,
    521     marquee: Boolean = false,
    522     trailing: (@Composable () -> Unit)? = null,
    523 ) {
    524     Text(
    525         modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
    526         text = label,
    527         style = MaterialTheme.typography.bodyMedium,
    528     )
    529 
    530     Row(
    531         modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp),
    532         verticalAlignment = Alignment.CenterVertically,
    533     ) {
    534         Text(
    535             modifier = if (marquee) Modifier.basicMarquee() else Modifier,
    536             text = info,
    537             fontSize = 24.sp,
    538         )
    539 
    540         trailing?.let { it() }
    541     }
    542 }
    543 
    544 private fun handleTransactionAction(
    545     tx: Transaction,
    546     action: TransactionAction,
    547     model: MainViewModel,
    548     onNavigateBack: () -> Unit,
    549 ) {
    550     val transactionManager = model.transactionManager
    551     val onError: (TalerErrorInfo) -> Unit = { error ->
    552         Log.e(TAG, "Error handling transaction action $action: $error")
    553     }
    554 
    555     when (action) {
    556         TransactionAction.Delete -> transactionManager.deleteTransaction(tx.transactionId, onError).invokeOnCompletion { onNavigateBack() }
    557         TransactionAction.Retry -> transactionManager.retryTransaction(tx.transactionId, onError)
    558         TransactionAction.Abort -> transactionManager.abortTransaction(tx.transactionId, { onNavigateBack() }, onError)
    559         TransactionAction.Fail -> transactionManager.failTransaction(tx.transactionId, onError)
    560         TransactionAction.Suspend -> transactionManager.suspendTransaction(tx.transactionId, onError)
    561         TransactionAction.Resume -> transactionManager.resumeTransaction(tx.transactionId, onError)
    562     }
    563 }
    564 
    565 @Preview
    566 @Composable
    567 fun TransactionRefreshComposablePreview() {
    568     val t = TransactionRefresh(
    569         transactionId = "transactionId",
    570         timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
    571         txState = TransactionState(TransactionMajorState.Pending),
    572         txActions = listOf(TransactionAction.Retry, TransactionAction.Suspend, TransactionAction.Abort),
    573         amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
    574         amountEffective = Amount.fromString("TESTKUDOS", "42.1337"),
    575         error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED),
    576         scopes = listOf(ScopeInfo.Exchange(
    577             currency = "TESTKUDOS",
    578             url = "exchange.test.taler.net",
    579         ))
    580     )
    581     Surface {
    582         TransactionRefreshComposable(t, true, null) {}
    583     }
    584 }