taler-android

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

WithdrawalShowInfo.kt (18685B)


      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.withdraw
     18 
     19 import androidx.compose.foundation.layout.Column
     20 import androidx.compose.foundation.layout.fillMaxSize
     21 import androidx.compose.foundation.layout.fillMaxWidth
     22 import androidx.compose.foundation.layout.imePadding
     23 import androidx.compose.foundation.layout.padding
     24 import androidx.compose.foundation.layout.size
     25 import androidx.compose.foundation.rememberScrollState
     26 import androidx.compose.foundation.verticalScroll
     27 import androidx.compose.material.icons.Icons
     28 import androidx.compose.material.icons.filled.ArrowDropDown
     29 import androidx.compose.material.icons.filled.Edit
     30 import androidx.compose.material3.Button
     31 import androidx.compose.material3.CircularProgressIndicator
     32 import androidx.compose.material3.DropdownMenu
     33 import androidx.compose.material3.DropdownMenuItem
     34 import androidx.compose.material3.Icon
     35 import androidx.compose.material3.IconButton
     36 import androidx.compose.material3.Text
     37 import androidx.compose.runtime.Composable
     38 import androidx.compose.runtime.LaunchedEffect
     39 import androidx.compose.runtime.getValue
     40 import androidx.compose.runtime.mutableStateOf
     41 import androidx.compose.runtime.remember
     42 import androidx.compose.runtime.setValue
     43 import androidx.compose.ui.Alignment
     44 import androidx.compose.ui.Modifier
     45 import androidx.compose.ui.focus.FocusRequester
     46 import androidx.compose.ui.focus.focusRequester
     47 import androidx.compose.ui.platform.LocalSoftwareKeyboardController
     48 import androidx.compose.ui.res.stringResource
     49 import androidx.compose.ui.tooling.preview.Preview
     50 import androidx.compose.ui.unit.dp
     51 import net.taler.common.Amount
     52 import net.taler.wallet.R
     53 import net.taler.wallet.backend.TalerErrorCode
     54 import net.taler.wallet.backend.TalerErrorInfo
     55 import net.taler.wallet.balances.ScopeInfo
     56 import net.taler.wallet.cleanExchange
     57 import net.taler.wallet.compose.AmountScope
     58 import net.taler.wallet.compose.AmountScopeField
     59 import net.taler.wallet.compose.BottomButtonBox
     60 import net.taler.wallet.compose.ErrorComposable
     61 import net.taler.wallet.compose.LoadingScreen
     62 import net.taler.wallet.compose.TalerSurface
     63 import net.taler.wallet.compose.WarningLabel
     64 import net.taler.wallet.exchanges.ExchangeItem
     65 import net.taler.wallet.exchanges.ExchangeTosStatus
     66 import net.taler.wallet.systemBarsPaddingBottom
     67 import net.taler.wallet.transactions.AmountType
     68 import net.taler.wallet.transactions.TransactionAmountComposable
     69 import net.taler.wallet.transactions.TransactionInfoComposable
     70 import net.taler.wallet.useDebounce
     71 import net.taler.wallet.withdraw.WithdrawStatus.Status.Error
     72 import net.taler.wallet.withdraw.WithdrawStatus.Status.TosReviewRequired
     73 import net.taler.wallet.withdraw.WithdrawStatus.Status.Updating
     74 import net.taler.wallet.withdraw.WithdrawalOperationStatusFlag.Pending
     75 
     76 @Composable
     77 fun WithdrawalShowInfo(
     78     status: WithdrawStatus,
     79     devMode: Boolean,
     80     initialAmountScope: AmountScope?,
     81     editableScope: Boolean,
     82     scopes: List<ScopeInfo>,
     83     onSelectAmount: (amount: Amount, scope: ScopeInfo) -> Unit,
     84     onSelectExchange: () -> Unit,
     85     onTosReview: () -> Unit,
     86     onConfirm: (age: Int?) -> Unit,
     87     modifier: Modifier = Modifier,
     88 ) {
     89     val maxAmount = status.uriInfo?.maxAmount
     90     val editableAmount = status.uriInfo?.editableAmount ?: true
     91     val wireFee = status.uriInfo?.wireFee
     92     val exchange = status.exchangeBaseUrl
     93     val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList()
     94     val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList()
     95 
     96     if (scopes.isEmpty()) {
     97         LoadingScreen(modifier)
     98         return
     99     }
    100 
    101     val keyboardController = LocalSoftwareKeyboardController.current
    102     var selectedAmount by remember(initialAmountScope != null) {
    103         mutableStateOf(initialAmountScope
    104             ?: AmountScope(
    105                 amount = Amount.zero(scopes.first().currency),
    106                 scope = scopes.first(),
    107                 userInput = false,
    108             ),
    109         )
    110     }
    111     var selectedAge by remember { mutableStateOf<Int?>(null) }
    112     val scrollState = rememberScrollState()
    113     val insufficientBalance = remember(selectedAmount, maxAmount) {
    114         val amount = selectedAmount.amount
    115         if (maxAmount != null && amount.currency == maxAmount.currency) {
    116             amount > maxAmount
    117         } else {
    118             false
    119         }
    120     }
    121 
    122     val focusRequester = remember { FocusRequester() }
    123 
    124     LaunchedEffect(selectedAmount) {
    125         val selected = selectedAmount
    126         if (!selected.debounce && selected.userInput) {
    127             onSelectAmount(
    128                 selected.amount,
    129                 selected.scope,
    130             )
    131         }
    132     }
    133 
    134     selectedAmount.useDebounce {
    135         val selected = selectedAmount
    136         if (selected.debounce && selected.userInput) {
    137             onSelectAmount(
    138                 selected.amount,
    139                 selected.scope,
    140             )
    141         }
    142     }
    143 
    144     Column(
    145         modifier
    146         .fillMaxSize()
    147         .imePadding(),
    148     ) {
    149         Column(
    150             modifier = Modifier
    151                 .weight(1f)
    152                 .verticalScroll(scrollState)
    153                 .fillMaxWidth(),
    154             horizontalAlignment = Alignment.CenterHorizontally,
    155         ) {
    156             if (editableScope) AmountScopeField(
    157                 modifier = Modifier
    158                     .padding(horizontal = 16.dp)
    159                     .fillMaxWidth(),
    160                 amount = selectedAmount.copy(
    161                     amount = selectedAmount.amount.withSpec(status.selectedSpec)
    162                 ),
    163                 scopes = scopes,
    164                 showAmount = false,
    165                 showScope = true,
    166                 showShortcuts = false,
    167                 readOnly = status.status == Updating,
    168                 onAmountChanged = { amount ->
    169                     selectedAmount = amount.copy(
    170                         amount = Amount.zero(amount.amount.currency),
    171                         debounce = false,
    172                         userInput = true,
    173                     )
    174                 },
    175             )
    176 
    177             if (status.status == WithdrawStatus.Status.Loading
    178                 || status.status == WithdrawStatus.Status.None) {
    179                 LoadingScreen(Modifier.weight(1f))
    180                 return
    181             } else if (status.status == Error && status.error != null) {
    182                 ErrorComposable(status.error,
    183                     modifier = Modifier.fillMaxSize(),
    184                     devMode = devMode)
    185                 return
    186             } else if (status.isCashAcceptor) {
    187                 WarningLabel(
    188                     label = stringResource(R.string.withdraw_cash_acceptor),
    189                     modifier = Modifier
    190                         .padding(16.dp)
    191                         .fillMaxWidth(),
    192                 )
    193             } else if (editableAmount) {
    194                 AmountScopeField(
    195                     modifier = Modifier
    196                         .padding(horizontal = 16.dp)
    197                         .padding(bottom = 16.dp)
    198                         .fillMaxWidth()
    199                         .focusRequester(focusRequester),
    200                     amount = selectedAmount.copy(
    201                         amount = selectedAmount.amount.withSpec(status.selectedSpec)),
    202                     scopes = scopes,
    203                     showScope = false,
    204                     showAmount = status.status != TosReviewRequired,
    205                     readOnly = status.status == Updating,
    206                     onAmountChanged = { amount ->
    207                         selectedAmount = amount.copy(
    208                             debounce = true,
    209                             userInput = true,
    210                         )
    211                     },
    212                     label = { Text(stringResource(R.string.amount_withdraw)) },
    213                     isError = selectedAmount.amount.isZero() || insufficientBalance,
    214                     supportingText = {
    215                         if (insufficientBalance && maxAmount != null) {
    216                             Text(stringResource(R.string.amount_excess, maxAmount))
    217                         }
    218                     },
    219                     showShortcuts = true,
    220                     onShortcutSelected = { amount ->
    221                         selectedAmount = amount.copy(
    222                             debounce = false,
    223                             userInput = true,
    224                         )
    225                     }
    226                 )
    227 
    228                 LaunchedEffect(Unit) {
    229                     focusRequester.requestFocus()
    230                 }
    231 
    232                 if (status.status == TosReviewRequired) Text(
    233                     modifier = Modifier.padding(22.dp),
    234                     text = stringResource(R.string.withdraw_review_terms),
    235                 )
    236             } else if (status.amountInfo != null) {
    237                 TransactionAmountComposable(
    238                     label = if (wireFee != null && wireFee.isZero()) {
    239                         stringResource(R.string.amount_total)
    240                     } else {
    241                         stringResource(R.string.amount_chosen)
    242                     },
    243                     amount = status.amountInfo.amountRaw.withSpec(status.selectedSpec),
    244                     amountType = if (wireFee != null && wireFee.isZero()) {
    245                         AmountType.Positive
    246                     } else {
    247                         AmountType.Neutral
    248                     },
    249                 )
    250             }
    251 
    252             if (status.status != TosReviewRequired
    253                 && status.amountInfo != null
    254                 && wireFee != null
    255                 && !wireFee.isZero()) {
    256                 TransactionAmountComposable(
    257                     label = stringResource(R.string.amount_fee),
    258                     amount = wireFee.withSpec(status.selectedSpec),
    259                     amountType = AmountType.Negative,
    260                 )
    261 
    262                 TransactionAmountComposable(
    263                     label = stringResource(R.string.amount_total),
    264                     amount = status.amountInfo.amountEffective.withSpec(status.selectedSpec) + wireFee,
    265                     amountType = AmountType.Positive,
    266                 )
    267             }
    268 
    269             if (exchange != null && selectedAmount.scope !is ScopeInfo.Exchange) {
    270                 TransactionInfoComposable(
    271                     label = stringResource(R.string.withdraw_exchange),
    272                     info = cleanExchange(exchange),
    273                     marquee = true,
    274                     trailing = {
    275                         if (devMode && possibleExchanges.size > 1) {
    276                             IconButton(
    277                                 modifier = Modifier.padding(start = 8.dp),
    278                                 onClick = { onSelectExchange() },
    279                             ) {
    280                                 Icon(
    281                                     Icons.Default.Edit,
    282                                     contentDescription = stringResource(R.string.edit),
    283                                 )
    284                             }
    285                         }
    286                     },
    287                 )
    288             }
    289 
    290             var expanded by remember { mutableStateOf(false) }
    291 
    292             if (status.status != TosReviewRequired && ageRestrictionOptions.isNotEmpty()) {
    293                 TransactionInfoComposable(
    294                     label = stringResource(R.string.withdraw_restrict_age),
    295                     info = selectedAge?.toString()
    296                         ?: stringResource(R.string.withdraw_restrict_age_unrestricted)
    297                 ) {
    298                     IconButton(
    299                         modifier = Modifier.padding(start = 8.dp),
    300                         onClick = { expanded = true }) {
    301                         Icon(
    302                             Icons.Default.ArrowDropDown,
    303                             contentDescription = stringResource(R.string.edit),
    304                         )
    305                     }
    306 
    307                     DropdownMenu(
    308                         expanded = expanded,
    309                         onDismissRequest = { expanded = false },
    310                     ) {
    311                         DropdownMenuItem(
    312                             text = { Text(stringResource(R.string.withdraw_restrict_age_unrestricted)) },
    313                             onClick = {
    314                                 selectedAge = null
    315                                 expanded = false
    316                             },
    317                         )
    318 
    319                         ageRestrictionOptions.forEach { age ->
    320                             DropdownMenuItem(
    321                                 text = { Text(age.toString()) },
    322                                 onClick = {
    323                                     selectedAge = age
    324                                     expanded = false
    325                                 },
    326                             )
    327                         }
    328                     }
    329                 }
    330             }
    331         }
    332 
    333         BottomButtonBox(Modifier.fillMaxWidth()) {
    334             Button(
    335                 modifier = Modifier
    336                     .systemBarsPaddingBottom(),
    337                 enabled = status.status != Updating
    338                         && (status.isCashAcceptor
    339                         || status.status == TosReviewRequired
    340                         || status.selectedAmount?.isZero() == false),
    341                 onClick = {
    342                     keyboardController?.hide()
    343                     if (status.status == TosReviewRequired) {
    344                         onTosReview()
    345                     } else onConfirm(selectedAge)
    346                 },
    347             ) {
    348                 when (status.status) {
    349                     Updating -> CircularProgressIndicator(modifier = Modifier.size(15.dp))
    350                     TosReviewRequired -> Text(stringResource(R.string.withdraw_button_tos))
    351                     else -> Text(stringResource(R.string.withdraw_button_confirm))
    352                 }
    353             }
    354         }
    355     }
    356 }
    357 
    358 private fun buildPreviewWithdrawStatus(
    359     status: WithdrawStatus.Status,
    360 ) = WithdrawStatus(
    361     status = status,
    362     talerWithdrawUri = "taler://",
    363     exchangeBaseUrl = "exchange.head.taler.net",
    364     transactionId = "tx:343434",
    365     error = TalerErrorInfo(TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE),
    366     uriInfo = WithdrawalDetailsForUri(
    367         amount = null,
    368         currency = "KUDOS",
    369         editableAmount = true,
    370         status = Pending,
    371         maxAmount = Amount.fromJSONString("KUDOS:10"),
    372         wireFee = Amount.fromJSONString("KUDOS:0.2"),
    373         defaultExchangeBaseUrl = "exchange.head.taler.net",
    374         possibleExchanges = listOf(
    375             ExchangeItem(
    376                 exchangeBaseUrl = "exchange.demo.taler.net",
    377                 currency = "KUDOS",
    378                 paytoUris = emptyList(),
    379                 scopeInfo = null,
    380                 tosStatus = ExchangeTosStatus.Accepted,
    381             ),
    382             ExchangeItem(
    383                 exchangeBaseUrl = "exchange.head.taler.net",
    384                 currency = "KUDOS",
    385                 paytoUris = emptyList(),
    386                 scopeInfo = null,
    387                 tosStatus = ExchangeTosStatus.Accepted,
    388             ),
    389         ),
    390     ),
    391     amountInfo = WithdrawalDetailsForAmount(
    392         amountRaw = Amount.fromJSONString("KUDOS:10.1"),
    393         amountEffective = Amount.fromJSONString("KUDOS:10.2"),
    394         withdrawalAccountsList = emptyList(),
    395         ageRestrictionOptions = listOf(18, 23),
    396         scopeInfo = ScopeInfo.Exchange(
    397             currency = "KUDOS",
    398             url = "exchange.head.taler.net",
    399         ),
    400     )
    401 )
    402 
    403 @Preview
    404 @Composable
    405 fun WithdrawalShowInfoUpdatingPreview() {
    406     TalerSurface {
    407         WithdrawalShowInfo(
    408             status = buildPreviewWithdrawStatus(Updating),
    409             devMode = true,
    410             initialAmountScope = AmountScope(
    411                 amount = Amount.fromJSONString("KUDOS:10.10"),
    412                 scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    413             ),
    414             editableScope = true,
    415             scopes = listOf(
    416                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    417                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    418                 ScopeInfo.Global("CHF"),
    419             ),
    420             onSelectExchange = {},
    421             onSelectAmount = { _, _ -> },
    422             onTosReview = {},
    423             onConfirm = {},
    424         )
    425     }
    426 }
    427 
    428 @Preview
    429 @Composable
    430 fun WithdrawalShowInfoTosReviewPreview() {
    431     TalerSurface {
    432         WithdrawalShowInfo(
    433             status = buildPreviewWithdrawStatus(TosReviewRequired),
    434             devMode = true,
    435             initialAmountScope = AmountScope(
    436                 amount = Amount.fromJSONString("KUDOS:10.10"),
    437                 scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    438             ),
    439             editableScope = true,
    440             scopes = listOf(
    441                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    442                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    443                 ScopeInfo.Global("CHF"),
    444             ),
    445             onSelectExchange = {},
    446             onSelectAmount = { _, _ -> },
    447             onTosReview = {},
    448             onConfirm = {},
    449         )
    450     }
    451 }
    452 
    453 @Preview
    454 @Composable
    455 fun WithdrawalShowInfoErrorPreview() {
    456     TalerSurface {
    457         WithdrawalShowInfo(
    458             status = buildPreviewWithdrawStatus(Error),
    459             devMode = true,
    460             initialAmountScope = AmountScope(
    461                 amount = Amount.fromJSONString("KUDOS:10.10"),
    462                 scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    463             ),
    464             editableScope = true,
    465             scopes = listOf(
    466                 ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"),
    467                 ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"),
    468                 ScopeInfo.Global("CHF"),
    469             ),
    470             onSelectExchange = {},
    471             onSelectAmount = { _, _ -> },
    472             onTosReview = {},
    473             onConfirm = {},
    474         )
    475     }
    476 }