taler-android

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

ScreenTransfer.kt (17496B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2025 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.transfer
     18 
     19 import androidx.compose.foundation.layout.Column
     20 import androidx.compose.foundation.layout.Row
     21 import androidx.compose.foundation.layout.Spacer
     22 import androidx.compose.foundation.layout.fillMaxWidth
     23 import androidx.compose.foundation.layout.height
     24 import androidx.compose.foundation.layout.padding
     25 import androidx.compose.foundation.layout.size
     26 import androidx.compose.foundation.rememberScrollState
     27 import androidx.compose.foundation.verticalScroll
     28 import androidx.compose.material.icons.Icons
     29 import androidx.compose.material.icons.filled.ContentCopy
     30 import androidx.compose.material3.Button
     31 import androidx.compose.material3.ButtonDefaults
     32 import androidx.compose.material3.Icon
     33 import androidx.compose.material3.MaterialTheme
     34 import androidx.compose.material3.ScrollableTabRow
     35 import androidx.compose.material3.Surface
     36 import androidx.compose.material3.Tab
     37 import androidx.compose.material3.Text
     38 import androidx.compose.material3.TextButton
     39 import androidx.compose.runtime.Composable
     40 import androidx.compose.runtime.LaunchedEffect
     41 import androidx.compose.runtime.getValue
     42 import androidx.compose.runtime.mutableStateMapOf
     43 import androidx.compose.runtime.mutableStateOf
     44 import androidx.compose.runtime.remember
     45 import androidx.compose.runtime.setValue
     46 import androidx.compose.ui.Alignment
     47 import androidx.compose.ui.Modifier
     48 import androidx.compose.ui.platform.LocalContext
     49 import androidx.compose.ui.res.stringResource
     50 import androidx.compose.ui.text.AnnotatedString
     51 import androidx.compose.ui.text.font.FontFamily
     52 import androidx.compose.ui.text.fromHtml
     53 import androidx.compose.ui.text.style.LineBreak
     54 import androidx.compose.ui.text.style.TextAlign
     55 import androidx.compose.ui.tooling.preview.Preview
     56 import androidx.compose.ui.unit.dp
     57 import net.taler.common.Amount
     58 import net.taler.common.CurrencySpecification
     59 import net.taler.lib.android.canAppHandleUri
     60 import net.taler.lib.android.copyToClipBoard
     61 import net.taler.wallet.BottomInsetsSpacer
     62 import net.taler.wallet.CURRENCY_BTC
     63 import net.taler.wallet.R
     64 import net.taler.wallet.compose.ShareButton
     65 import net.taler.wallet.transactions.AccountRestriction
     66 import net.taler.wallet.transactions.AmountType
     67 import net.taler.wallet.transactions.TransactionAmountComposable
     68 import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails
     69 import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails.Status.Ok
     70 import net.taler.wallet.withdraw.QrCodeSpec
     71 import net.taler.wallet.withdraw.QrCodeSpec.Type.EpcQr
     72 import net.taler.wallet.withdraw.QrCodeSpec.Type.SPC
     73 import net.taler.wallet.withdraw.TransferData
     74 
     75 sealed class TransferContext {
     76     data object ManualWithdrawal : TransferContext()
     77     data class DepositKycAuth(val debitPaytoUri: String) : TransferContext()
     78 }
     79 
     80 @Composable
     81 fun ScreenTransfer(
     82     modifier: Modifier = Modifier,
     83     transfers: List<TransferData>,
     84     spec: CurrencySpecification?,
     85     showQrCodes: Boolean,
     86     getQrCodes: (account: TransferData) -> List<QrCodeSpec>,
     87     bankAppClick: ((transfer: TransferData) -> Unit)?,
     88     shareClick: ((transfer: TransferData) -> Unit)?,
     89     devMode: Boolean = false,
     90     transferContext: TransferContext,
     91 ) {
     92     // TODO: show some placeholder
     93     if (transfers.isEmpty()) return
     94 
     95     val transfers = transfers.filter {
     96         // TODO: in dev mode, show debug info when status is `Error'
     97         it.withdrawalAccount.status == Ok
     98     }.sortedByDescending {
     99         it.withdrawalAccount.priority
    100     }
    101 
    102     val defaultTransfer = transfers[0]
    103     var selectedTransfer by remember { mutableStateOf(defaultTransfer) }
    104     val qrCodes = remember(selectedTransfer) { getQrCodes(selectedTransfer) }
    105     val qrExpandedStates = remember(qrCodes) {
    106         val map = mutableStateMapOf<QrCodeSpec, Boolean>()
    107         qrCodes.forEach {
    108             map[it] = false
    109         }
    110         map
    111     }
    112 
    113     LaunchedEffect(Unit) {
    114         getQrCodes(defaultTransfer)
    115     }
    116 
    117     Column(modifier) {
    118         if (transfers.size > 1) {
    119             TransferAccountChooser(
    120                 accounts = transfers.map { it.withdrawalAccount },
    121                 selectedAccount = selectedTransfer.withdrawalAccount,
    122                 onSelectAccount = { account ->
    123                     transfers.find {
    124                         it.withdrawalAccount.paytoUri == account.paytoUri
    125                     }?.let { selectedTransfer = it }
    126                 }
    127             )
    128         }
    129 
    130         val scrollState = rememberScrollState()
    131         Column(
    132             modifier = Modifier
    133                 .verticalScroll(scrollState),
    134             horizontalAlignment = Alignment.CenterHorizontally,
    135         ) {
    136             if (showQrCodes) {
    137                 Text(
    138                     text = stringResource(R.string.withdraw_manual_qr_intro),
    139                     style = MaterialTheme.typography.bodyLarge,
    140                     modifier = Modifier
    141                         .padding(
    142                             vertical = 8.dp,
    143                             horizontal = 16.dp,
    144                         )
    145                 )
    146 
    147                 qrCodes.forEach { spec ->
    148                     PaytoQrCard(
    149                         expanded = qrExpandedStates[spec]!!,
    150                         setExpanded = { expanded ->
    151                             if (expanded) { // un-expand all others
    152                                 qrExpandedStates.forEach { (k, _) ->
    153                                     qrExpandedStates[k] = false
    154                                 }
    155                             }
    156                             // expand only toggled one
    157                             qrExpandedStates[spec] = expanded
    158                         },
    159                         qrCode = spec,
    160                     )
    161                 }
    162 
    163                 BottomInsetsSpacer()
    164                 return
    165             }
    166 
    167             when (val transfer = selectedTransfer) {
    168                 is TransferData.Taler -> TransferTaler(
    169                     transfer = transfer,
    170                     transactionAmountEffective = transfer.amountEffective.withSpec(spec),
    171                     transferContext = transferContext,
    172                 )
    173 
    174                 is TransferData.IBAN -> TransferIBAN(
    175                     transfer = transfer,
    176                     transactionAmountEffective = transfer.amountEffective.withSpec(spec),
    177                     transferContext = transferContext,
    178                 )
    179 
    180                 is TransferData.Cyclos -> TransferCyclos(
    181                     transfer = transfer,
    182                     transactionAmountEffective = transfer.amountEffective.withSpec(spec),
    183                 )
    184 
    185                 is TransferData.Bitcoin -> TransferBitcoin(
    186                     transfer = transfer,
    187                 )
    188             }
    189 
    190             Spacer(Modifier.height(24.dp))
    191 
    192             if (devMode) {
    193                 val paytoUri = selectedTransfer.withdrawalAccount.paytoUri
    194                 if (bankAppClick != null && LocalContext.current.canAppHandleUri(paytoUri)) {
    195                     Button(
    196                         onClick = { bankAppClick(selectedTransfer) },
    197                         modifier = Modifier
    198                             .padding(bottom = 16.dp),
    199                     ) {
    200                         Text(text = stringResource(R.string.withdraw_manual_ready_bank_button))
    201                     }
    202                 }
    203 
    204                 if (shareClick != null) {
    205                     ShareButton(
    206                         content = selectedTransfer.withdrawalAccount.paytoUri,
    207                         modifier = Modifier
    208                             .padding(bottom = 16.dp),
    209                     )
    210                 }
    211             }
    212 
    213             BottomInsetsSpacer()
    214         }
    215     }
    216 }
    217 
    218 @Composable
    219 fun TransferStep(
    220     index: Int,
    221     description: String,
    222 ) {
    223     Text(
    224         modifier = Modifier.padding(
    225             top = 16.dp,
    226             start = 6.dp,
    227             end = 6.dp,
    228             bottom = 6.dp,
    229         ),
    230         text = AnnotatedString.fromHtml(
    231             stringResource(
    232                 R.string.withdraw_manual_step,
    233                 index,
    234                 description,
    235             )
    236         ),
    237         style = MaterialTheme.typography.bodyMedium,
    238     )
    239 }
    240 
    241 @Composable
    242 fun DetailRow(
    243     label: String,
    244     content: String,
    245     copy: Boolean = true,
    246     characterBreak: Boolean = false,
    247 ) {
    248     val context = LocalContext.current
    249 
    250     Column(
    251         modifier = Modifier.fillMaxWidth(),
    252         horizontalAlignment = Alignment.CenterHorizontally,
    253     ) {
    254         Text(
    255             modifier = Modifier.padding(top = 16.dp, start = 6.dp, end = 6.dp),
    256             text = label,
    257             style = MaterialTheme.typography.bodyMedium,
    258         )
    259 
    260         Row(
    261             verticalAlignment = Alignment.CenterVertically,
    262         ) {
    263             Text(
    264                 modifier = Modifier.padding(
    265                     top = 8.dp,
    266                     start = 6.dp,
    267                     end = 6.dp,
    268                 ).weight(1f),
    269                 text = content,
    270                 style = if (characterBreak) {
    271                     MaterialTheme.typography.bodyLarge.copy(
    272                         lineBreak = LineBreak.Heading,
    273                     )
    274                 } else MaterialTheme.typography.bodyLarge,
    275                 fontFamily = if (copy) FontFamily.Monospace else FontFamily.Default,
    276                 textAlign = TextAlign.Center,
    277             )
    278 
    279             if (copy) {
    280                 TextButton(
    281                     onClick = { copyToClipBoard(context, label, content) },
    282                 ) {
    283                     Icon(
    284                         Icons.Default.ContentCopy,
    285                         contentDescription = null,
    286                         modifier = Modifier.size(ButtonDefaults.IconSize),
    287                     )
    288                     Spacer(Modifier.size(ButtonDefaults.IconSpacing))
    289                     Text(stringResource(R.string.copy))
    290                 }
    291             }
    292         }
    293     }
    294 }
    295 
    296 @Composable
    297 fun WithdrawalAmountTransfer(
    298     conversionAmountRaw: Amount,
    299 ) {
    300     Column(
    301         modifier = Modifier.fillMaxWidth(),
    302         horizontalAlignment = Alignment.CenterHorizontally,
    303     ) {
    304         TransactionAmountComposable(
    305             label = stringResource(R.string.amount_transfer),
    306             amount = conversionAmountRaw,
    307             amountType = AmountType.Neutral,
    308             context = LocalContext.current,
    309             copy = true,
    310         )
    311     }
    312 }
    313 
    314 @Composable
    315 fun TransferAccountChooser(
    316     modifier: Modifier = Modifier,
    317     accounts: List<WithdrawalExchangeAccountDetails>,
    318     selectedAccount: WithdrawalExchangeAccountDetails,
    319     onSelectAccount: (account: WithdrawalExchangeAccountDetails) -> Unit,
    320 ) {
    321     val selectedIndex = accounts.indexOfFirst {
    322         it.paytoUri == selectedAccount.paytoUri
    323     }
    324 
    325     ScrollableTabRow(
    326         selectedTabIndex = selectedIndex,
    327         modifier = modifier,
    328         edgePadding = 8.dp,
    329     ) {
    330         accounts.forEachIndexed { index, account ->
    331             Tab(
    332                 selected = selectedAccount.paytoUri == account.paytoUri,
    333                 onClick = { onSelectAccount(account) },
    334                 text = {
    335                     if (!account.bankLabel.isNullOrEmpty()) {
    336                         Text(account.bankLabel)
    337                     } else if (account.currencySpecification?.name != null) {
    338                         Text(stringResource(
    339                             R.string.withdraw_account_currency,
    340                             index + 1,
    341                             account.currencySpecification.name,
    342                         ))
    343                     } else if (account.transferAmount?.currency != null) {
    344                         Text(stringResource(
    345                             R.string.withdraw_account_currency,
    346                             index + 1,
    347                             account.transferAmount.currency,
    348                         ))
    349                     } else Text(stringResource(R.string.withdraw_account, index + 1))
    350                 },
    351             )
    352         }
    353     }
    354 }
    355 
    356 @Preview
    357 @Composable
    358 fun ScreenTransferPreview(
    359     showQrCodes: Boolean = false,
    360     transferContext: TransferContext = TransferContext.ManualWithdrawal,
    361 ) {
    362     Surface {
    363         ScreenTransfer(
    364             transfers = listOf(
    365                 TransferData.IBAN(
    366                     iban = "ASDQWEASDZXCASDQWE",
    367                     subject = "Taler Withdrawal P2T19EXRBY4B145JRNZ8CQTD7TCS03JE9VZRCEVKVWCP930P56WG",
    368                     amountRaw = Amount("KUDOS", 10, 0),
    369                     amountEffective = Amount("KUDOS", 9, 5),
    370                     transferAmount = Amount("KUDOS", 10, 0),
    371                     receiverTown = "Biel/Bienne",
    372                     receiverPostalCode = "2500",
    373                     withdrawalAccount = WithdrawalExchangeAccountDetails(
    374                         paytoUri = "https://taler.net/kudos",
    375                         transferAmount = Amount("KUDOS", 10, 0),
    376                         status = Ok,
    377                         currencySpecification = CurrencySpecification(
    378                             "KUDOS",
    379                             numFractionalInputDigits = 2,
    380                             numFractionalNormalDigits = 2,
    381                             numFractionalTrailingZeroDigits = 2,
    382                             altUnitNames = emptyMap(),
    383                         ),
    384                         creditRestrictions = listOf(
    385                             AccountRestriction.RegexAccount(
    386                                 paytoRegex = "CH",
    387                                 humanHint = "Only Swiss bank accounts",
    388                                 humanHintI18n = emptyMap(),
    389                             ),
    390                             AccountRestriction.RegexAccount(
    391                                 paytoRegex = "DE",
    392                                 humanHint = "Only European bank accounts",
    393                                 humanHintI18n = emptyMap(),
    394                             )
    395                         )
    396                     ),
    397                 ),
    398                 TransferData.Cyclos(
    399                     host = "demo.cyclos.org/abc",
    400                     account = "1234567890",
    401                     receiverName = "Taler Wire",
    402                     subject = "Taler Withdrawal P2T19EXRBY4B145JRNZ8CQTD7TCS03JE9VZRCEVKVWCP930P56WG",
    403                     amountRaw = Amount("IU", 10, 0),
    404                     amountEffective = Amount("IU", 9, 5),
    405                     transferAmount = Amount("IU", 10, 0),
    406                     withdrawalAccount = WithdrawalExchangeAccountDetails(
    407                         paytoUri = "https://taler.net/cyclos",
    408                         transferAmount = Amount("IU", 10, 0),
    409                         status = Ok,
    410                     ),
    411                 ),
    412                 TransferData.Bitcoin(
    413                     account = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
    414                     segwitAddresses = listOf(
    415                         "bc1qqleages8702xvg9qcyu02yclst24xurdrynvxq",
    416                         "bc1qsleagehks96u7jmqrzcf0fw80ea5g57qm3m84c"
    417                     ),
    418                     subject = "0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00",
    419                     amountRaw = Amount(CURRENCY_BTC, 0, 14000000),
    420                     amountEffective = Amount(CURRENCY_BTC, 0, 14000000),
    421                     transferAmount = Amount("KUDOS", 10, 0),
    422                     withdrawalAccount = WithdrawalExchangeAccountDetails(
    423                         paytoUri = "https://taler.net/btc",
    424                         transferAmount = Amount("BTC", 0, 14000000),
    425                         status = Ok,
    426                         currencySpecification = CurrencySpecification(
    427                             "Bitcoin",
    428                             numFractionalInputDigits = 2,
    429                             numFractionalNormalDigits = 2,
    430                             numFractionalTrailingZeroDigits = 2,
    431                             altUnitNames = emptyMap(),
    432                         ),
    433                     ),
    434                 ),
    435             ),
    436             spec = null,
    437             bankAppClick = {},
    438             shareClick = {},
    439             showQrCodes = showQrCodes,
    440             getQrCodes = {
    441                 listOf(
    442                     QrCodeSpec(EpcQr, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0"),
    443                     QrCodeSpec(SPC, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0")
    444                 )
    445             },
    446             transferContext = transferContext,
    447         )
    448     }
    449 }
    450 
    451 @Preview
    452 @Composable
    453 fun ScreenTransferKycAuthPreview() {
    454     ScreenTransferPreview(transferContext = TransferContext.DepositKycAuth("CH120912"))
    455 }
    456 
    457 @Preview
    458 @Composable
    459 fun ScreenTransferQRPreview() {
    460     ScreenTransferPreview(true)
    461 }