taler-android

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

PromptPaymentComposable.kt (43474B)


      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.payment
     18 
     19 import android.graphics.Bitmap
     20 import androidx.compose.animation.animateContentSize
     21 import androidx.compose.foundation.BorderStroke
     22 import androidx.compose.foundation.Image
     23 import androidx.compose.foundation.clickable
     24 import androidx.compose.foundation.layout.Box
     25 import androidx.compose.foundation.layout.Column
     26 import androidx.compose.foundation.layout.Row
     27 import androidx.compose.foundation.layout.fillMaxSize
     28 import androidx.compose.foundation.layout.fillMaxWidth
     29 import androidx.compose.foundation.layout.imePadding
     30 import androidx.compose.foundation.layout.padding
     31 import androidx.compose.foundation.layout.size
     32 import androidx.compose.foundation.lazy.LazyColumn
     33 import androidx.compose.foundation.relocation.BringIntoViewRequester
     34 import androidx.compose.foundation.relocation.bringIntoViewRequester
     35 import androidx.compose.material.icons.Icons
     36 import androidx.compose.material.icons.filled.Error
     37 import androidx.compose.material.icons.filled.EventRepeat
     38 import androidx.compose.material.icons.filled.Warning
     39 import androidx.compose.material3.AlertDialog
     40 import androidx.compose.material3.Button
     41 import androidx.compose.material3.Card
     42 import androidx.compose.material3.CardDefaults
     43 import androidx.compose.material3.ExperimentalMaterial3Api
     44 import androidx.compose.material3.Icon
     45 import androidx.compose.material3.IconButton
     46 import androidx.compose.material3.ListItem
     47 import androidx.compose.material3.ListItemDefaults
     48 import androidx.compose.material3.MaterialTheme
     49 import androidx.compose.material3.OutlinedButton
     50 import androidx.compose.material3.OutlinedCard
     51 import androidx.compose.material3.PlainTooltip
     52 import androidx.compose.material3.Text
     53 import androidx.compose.material3.TextButton
     54 import androidx.compose.material3.TooltipBox
     55 import androidx.compose.material3.TooltipDefaults
     56 import androidx.compose.material3.rememberTooltipState
     57 import androidx.compose.runtime.Composable
     58 import androidx.compose.runtime.LaunchedEffect
     59 import androidx.compose.runtime.getValue
     60 import androidx.compose.runtime.mutableIntStateOf
     61 import androidx.compose.runtime.mutableStateOf
     62 import androidx.compose.runtime.remember
     63 import androidx.compose.runtime.rememberCoroutineScope
     64 import androidx.compose.runtime.saveable.rememberSaveable
     65 import androidx.compose.runtime.setValue
     66 import androidx.compose.ui.Alignment
     67 import androidx.compose.ui.Alignment.Companion.CenterHorizontally
     68 import androidx.compose.ui.Modifier
     69 import androidx.compose.ui.graphics.asImageBitmap
     70 import androidx.compose.ui.res.painterResource
     71 import androidx.compose.ui.res.stringResource
     72 import androidx.compose.ui.tooling.preview.Preview
     73 import androidx.compose.ui.unit.dp
     74 import kotlinx.coroutines.launch
     75 import net.taler.common.Amount
     76 import net.taler.common.ContractChoice
     77 import net.taler.common.ContractInput
     78 import net.taler.common.ContractOutput
     79 import net.taler.common.ContractProduct
     80 import net.taler.common.ContractTerms
     81 import net.taler.common.ContractTokenDetails
     82 import net.taler.common.ContractTokenFamily
     83 import net.taler.common.Exchange
     84 import net.taler.common.Merchant
     85 import net.taler.common.TalerUtils
     86 import net.taler.lib.android.base64Bitmap
     87 import net.taler.wallet.R
     88 import net.taler.wallet.cleanExchange
     89 import net.taler.wallet.compose.BottomButtonBox
     90 import net.taler.wallet.compose.ExpandableSection
     91 import net.taler.wallet.compose.MerchantAvatar
     92 import net.taler.wallet.compose.TalerSurface
     93 import net.taler.wallet.compose.cardPaddings
     94 import net.taler.wallet.donau.DonauInfo
     95 import net.taler.wallet.donau.DonauSelector
     96 import net.taler.wallet.donau.DonauToggle
     97 import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.InsufficientBalance
     98 import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.PaymentPossible
     99 import net.taler.wallet.payment.TokenAvailabilityHint.MerchantUnexpected
    100 import net.taler.wallet.payment.TokenAvailabilityHint.MerchantUntrusted
    101 import net.taler.wallet.payment.TokenAvailabilityHint.WalletTokensAvailableInsufficient
    102 import net.taler.wallet.systemBarsPaddingBottom
    103 
    104 // TODO: error handling
    105 // TODO: info sheets
    106 
    107 @Composable
    108 fun PromptPaymentComposable(
    109     status: PayStatus.Choices,
    110     onConfirm: (choiceIndex: Int?, useDonau: Boolean) -> Unit,
    111     onCancel: () -> Unit,
    112     onClickImage: (Bitmap) -> Unit,
    113     onSetupDonau: (donauBaseUrl: String) -> Unit,
    114     checkDonauStatus: suspend (choiceIndex: Int) -> DonauStatus,
    115     modifier: Modifier = Modifier,
    116 ) {
    117     val contractTerms = status.contractTerms
    118     var showCancelDialog by rememberSaveable { mutableStateOf(false) }
    119 
    120     OrderCancelDialog(
    121         showCancelDialog,
    122         onDismiss = { showCancelDialog = false },
    123         onConfirm = { onCancel() },
    124     )
    125 
    126     Column(
    127         modifier
    128             .fillMaxSize()
    129             .imePadding(),
    130     ) {
    131         var orderExpanded by rememberSaveable {
    132             mutableStateOf(contractTerms is ContractTerms.V0)
    133         }
    134         var choicesExpanded by rememberSaveable { mutableStateOf(true) }
    135         var selectedIndex by rememberSaveable { mutableIntStateOf(status.defaultChoiceIndex ?: 0) }
    136         var donauStatus: DonauStatus by remember { mutableStateOf(DonauStatus.Unavailable) }
    137 
    138         LazyColumn(
    139             Modifier
    140                 .weight(1f)
    141                 .fillMaxWidth(),
    142         ) {
    143             item { MerchantSection(contractTerms, onClickImage) }
    144 
    145             // REVIEW ORDER SECTION
    146             item {
    147                 ExpandableSection(
    148                     expanded = orderExpanded,
    149                     setExpanded = { orderExpanded = it },
    150                     header = { Text(stringResource(R.string.payment_section_review)) },
    151                 ) {
    152                     OrderSection(contractTerms, onClickImage)
    153                 }
    154             }
    155 
    156             // PAYMENT OPTIONS SECTION
    157             if (contractTerms is ContractTerms.V1) {
    158                 item {
    159                     ExpandableSection(
    160                         expanded = choicesExpanded,
    161                         setExpanded = { choicesExpanded = it },
    162                         header = { Text(stringResource(R.string.payment_section_choices)) },
    163                     ) {
    164                         ChoicesSection(
    165                             status = status,
    166                             tokenFamilies = contractTerms.tokenFamilies,
    167                             selectedIndex = selectedIndex,
    168                             merchantBaseUrl = contractTerms.merchantBaseUrl,
    169                             onSelect = { index -> selectedIndex = index },
    170                             onConfirm = onConfirm,
    171                             donauStatus = donauStatus,
    172                             onSetupDonau = onSetupDonau,
    173                         )
    174                     }
    175                 }
    176 
    177                 item {
    178                     LaunchedEffect(selectedIndex) {
    179                         donauStatus = checkDonauStatus(selectedIndex)
    180                     }
    181                 }
    182             }
    183         }
    184 
    185         BottomButtonBox(Modifier.fillMaxWidth(),
    186             heading = if (contractTerms is ContractTerms.V0) { ->
    187                 val choice = status.choices.firstOrNull()
    188                     ?: error("no v0 choice")
    189 
    190                 Text(
    191                     stringResource(
    192                         R.string.payment_amount_total,
    193                         choice.details.amountRaw,
    194                     )
    195                 )
    196 
    197                 if (choice.details is PaymentPossible) {
    198                     PaymentFeeLabel(
    199                         modifier = Modifier.padding(bottom = 3.dp),
    200                         amountRaw = choice.details.amountRaw,
    201                         amountEffective = choice.details.amountEffective,
    202                     )
    203                 } else if (choice.details is InsufficientBalance) {
    204                     Text(
    205                         modifier = Modifier.padding(bottom = 3.dp),
    206                         text = stringResource(R.string.payment_balance_insufficient),
    207                         color = MaterialTheme.colorScheme.error,
    208                     )
    209                 }
    210             } else null,
    211             leading = {
    212                 OutlinedButton(
    213                     modifier = Modifier.systemBarsPaddingBottom(),
    214                     enabled = true,
    215                     onClick = { showCancelDialog = true },
    216                 ) {
    217                     Text(
    218                         stringResource(R.string.payment_button_cancel),
    219                         color = MaterialTheme.colorScheme.error
    220                     )
    221                 }
    222             },
    223             trailing = {
    224                 if (contractTerms is ContractTerms.V0) {
    225                     val choice = status.choices.firstOrNull()
    226                         ?: error("no v0 choice")
    227                     Button(
    228                         modifier = Modifier.systemBarsPaddingBottom(),
    229                         enabled = choice.details is PaymentPossible,
    230                         onClick = { onConfirm(null, false) },
    231                     ) {
    232                         if (choice.details is PaymentPossible) {
    233                             Text(stringResource(
    234                                 R.string.payment_button_confirm_amount,
    235                                 choice.details.amountEffective,
    236                             ))
    237                         } else {
    238                             Text(stringResource(
    239                                 R.string.payment_button_confirm_amount,
    240                                 choice.details.amountRaw,
    241                             ))
    242                         }
    243                     }
    244                 }
    245             },
    246         )
    247     }
    248 }
    249 
    250 @Composable
    251 fun MerchantSection(
    252     contractTerms: ContractTerms,
    253     onClickImage: (Bitmap) -> Unit,
    254 ) {
    255     val merchant = contractTerms.merchant
    256 
    257     Column(
    258         modifier = Modifier
    259             .padding(16.dp)
    260             .fillMaxWidth(),
    261         horizontalAlignment = CenterHorizontally,
    262     ) {
    263         // MERCHANT LOGO
    264         MerchantAvatar(contractTerms.merchant,  onClickImage = onClickImage)
    265 
    266         // MERCHANT NAME
    267         Text(
    268             merchant.name,
    269             modifier = Modifier.padding(top = 16.dp),
    270             style = MaterialTheme.typography.titleLarge,
    271         )
    272 
    273         // MERCHANT INFO BUTTON
    274         // TextButton(onClick = {}) {
    275         //     Icon(
    276         //         Icons.Outlined.Info,
    277         //         contentDescription = null,
    278         //         modifier = Modifier.size(ButtonDefaults.IconSize),
    279         //     )
    280         //     Spacer(Modifier.size(ButtonDefaults.IconSpacing))
    281         //     Text("Merchant info")
    282         // }
    283     }
    284 }
    285 
    286 @Composable
    287 fun MerchantInfoSheet() {}
    288 
    289 @Composable
    290 fun OrderCancelDialog(
    291     show: Boolean,
    292     onDismiss: () -> Unit,
    293     onConfirm: () -> Unit,
    294 ) {
    295     if (show) AlertDialog(
    296         title = { Text(stringResource(R.string.payment_cancel_dialog_title)) },
    297         text = { Text(stringResource(R.string.payment_cancel_dialog_message)) },
    298         onDismissRequest = { onDismiss() },
    299         confirmButton = {
    300             TextButton(onClick = {
    301                 onDismiss()
    302             }) {
    303                 Text(stringResource(R.string.button_back))
    304             }
    305         },
    306         dismissButton = {
    307             TextButton(onClick = {
    308                 onConfirm()
    309             }) {
    310                 Text(stringResource(R.string.payment_cancel_dialog_title))
    311             }
    312         },
    313     )
    314 }
    315 
    316 @Composable
    317 fun OrderSection(
    318     contractTerms: ContractTerms,
    319     onClickImage: (Bitmap) -> Unit,
    320 ) {
    321     Column(horizontalAlignment = CenterHorizontally) {
    322         // ORDER SUMMARY
    323         Text(
    324             contractTerms.summary,
    325             modifier = Modifier
    326                 .padding(top = 3.dp, bottom = 12.dp)
    327                 .padding(horizontal = 16.dp)
    328                 .fillMaxWidth(),
    329         )
    330 
    331         // PRODUCT LIST
    332         // TODO: LazyColumn would be better, but can't be nested
    333         val products = contractTerms.products
    334         products?.forEach { product ->
    335             ProductItem(product, onClickImage)
    336         }
    337 
    338         // ORDER INFO BUTTON
    339         // TextButton(
    340         //     onClick = {},
    341         //     modifier = Modifier.padding(bottom = 9.dp),
    342         // ) {
    343         //     Icon(
    344         //         Icons.Outlined.Info,
    345         //         contentDescription = null,
    346         //         modifier = Modifier.size(ButtonDefaults.IconSize),
    347         //     )
    348         //     Spacer(Modifier.size(ButtonDefaults.IconSpacing))
    349         //     Text("More details")
    350         // }
    351     }
    352 }
    353 
    354 @Composable
    355 fun ProductItem(
    356     product: ContractProduct,
    357     onClickImage: (Bitmap) -> Unit,
    358 ) {
    359     val image = remember(product.image) {
    360         product.image?.base64Bitmap
    361     }
    362 
    363     ListItem(
    364         leadingContent = {
    365             // IMAGE
    366             if (image != null) {
    367                 Image(
    368                     image.asImageBitmap(),
    369                     modifier = Modifier
    370                         .size(30.dp)
    371                         .clickable { onClickImage(image) },
    372                     contentDescription = null,
    373                 )
    374             }
    375         },
    376 
    377         headlineContent = {
    378             Column(
    379                 Modifier.fillMaxWidth(),
    380                 horizontalAlignment = Alignment.End,
    381             ) {
    382                 // NAME
    383                 Text(
    384                     product.localizedDescription,
    385                     modifier = Modifier.fillMaxWidth(),
    386                     style = MaterialTheme.typography.bodyLarge,
    387                 )
    388 
    389                 // PRICE
    390                 product.price?.let { price ->
    391                     Text(
    392                         if (product.quantity > 1) {
    393                             stringResource(
    394                                 R.string.payment_product_price_quantity,
    395                                 product.quantity.toString(),
    396                                 price.toString(),
    397                             )
    398                         } else {
    399                             price.toString()
    400                         },
    401                         style = MaterialTheme.typography.bodyMedium.copy(
    402                             color = ListItemDefaults.colors().supportingTextColor,
    403                         ),
    404                     )
    405                 }
    406             }
    407         },
    408     )
    409 }
    410 
    411 @Composable
    412 fun ChoicesSection(
    413     status: PayStatus.Choices,
    414     tokenFamilies: Map<String, ContractTokenFamily>,
    415     selectedIndex: Int,
    416     merchantBaseUrl: String,
    417     onSelect: (choiceIndex: Int) -> Unit,
    418     onConfirm: (choiceIndex: Int, useDonau: Boolean) -> Unit,
    419     donauStatus: DonauStatus,
    420     onSetupDonau: (donauBaseUrl: String) -> Unit,
    421 ) {
    422     // TODO: CURRENCIES
    423 
    424     // CHOICES
    425     // TODO: LazyColumn would be better, but can't be nested
    426     status.choices.forEach { choice ->
    427         PaymentChoice(
    428             choice = choice,
    429             tokenFamilies = tokenFamilies,
    430             merchantBaseUrl = merchantBaseUrl,
    431             selected = selectedIndex == choice.choiceIndex,
    432             onSelect = { onSelect(choice.choiceIndex) },
    433             donauStatus = donauStatus,
    434             onSetupDonau = onSetupDonau,
    435             onConfirm = { useDonau -> onConfirm(choice.choiceIndex, useDonau) },
    436         )
    437     }
    438 }
    439 
    440 @Composable
    441 fun PaymentChoice(
    442     choice: PayChoiceDetails,
    443     tokenFamilies: Map<String, ContractTokenFamily>,
    444     merchantBaseUrl: String,
    445     selected: Boolean,
    446     onSelect: () -> Unit,
    447     onConfirm: (useDonau: Boolean) -> Unit,
    448     donauStatus: DonauStatus,
    449     onSetupDonau: (donauBaseUrl: String) -> Unit,
    450 ) {
    451     val bringIntoViewRequester = remember { BringIntoViewRequester() }
    452     val coroutineScope = rememberCoroutineScope()
    453 
    454     OutlinedCard(
    455         modifier = Modifier
    456             .cardPaddings()
    457             .fillMaxWidth()
    458             .bringIntoViewRequester(bringIntoViewRequester)
    459             .animateContentSize { _, _ ->
    460                 if (selected) {
    461                     coroutineScope.launch {
    462                         bringIntoViewRequester.bringIntoView()
    463                     }
    464                 }
    465             }
    466             .clickable { onSelect() },
    467         border = if (selected) {
    468             BorderStroke(2.5.dp, MaterialTheme.colorScheme.primary)
    469         } else {
    470             CardDefaults.outlinedCardBorder()
    471         },
    472     ) {
    473         Column(Modifier) {
    474             Column(Modifier.padding(15.dp)) {
    475                 // CHOICE AMOUNT
    476                 Text(
    477                     text = if (choice.amountRaw.isZero()) {
    478                         stringResource(R.string.payment_button_confirm_tokens)
    479                     } else {
    480                         choice.amountRaw.toString()
    481                     },
    482                     style = MaterialTheme.typography.titleLarge,
    483                 )
    484 
    485                 if (choice.details is PaymentPossible) {
    486                     PaymentFeeLabel(
    487                         amountRaw = choice.details.amountRaw,
    488                         amountEffective = choice.details.amountEffective,
    489                     )
    490                 }
    491 
    492                 choice.localizedDescription?.let {
    493                     Text(
    494                         text = it,
    495                         modifier = Modifier.padding(top = 9.dp),
    496                         style = MaterialTheme.typography.bodyLarge,
    497                     )
    498                 }
    499 
    500                 // INPUTS
    501                 if (choice.inputs.isNotEmpty()) {
    502                     Column {
    503                         Text(
    504                             stringResource(R.string.payment_choice_inputs),
    505                             modifier = Modifier.padding(top = 9.dp, bottom = 3.dp),
    506                             style = MaterialTheme.typography.labelLarge,
    507                         )
    508 
    509                         choice.inputs.forEach { input ->
    510                             PaymentInput(input, merchantBaseUrl, tokenFamilies, choice.details.tokenDetails)
    511                         }
    512                     }
    513                 }
    514 
    515                 // OUTPUTS
    516                 if (choice.outputs.isNotEmpty()) {
    517                     Column {
    518                         Text(
    519                             stringResource(R.string.payment_choice_outputs),
    520                             modifier = Modifier.padding(top = 9.dp, bottom = 3.dp),
    521                             style = MaterialTheme.typography.labelLarge,
    522                         )
    523 
    524                         choice.outputs.forEach { output ->
    525                             PaymentOutput(output, tokenFamilies, merchantBaseUrl)
    526                         }
    527                     }
    528                 }
    529 
    530                 // DONAU TOGGLE/SELECTOR
    531                 var useDonau by rememberSaveable { mutableStateOf(false) }
    532                 var showSelector by remember { mutableStateOf(false) }
    533                 if (choice.details is PaymentPossible) {
    534                     if (selected) DonauToggle(
    535                         donauStatus = donauStatus,
    536                         useDonau = useDonau,
    537                         onToggleDonau = { useDonau = it },
    538                     )
    539 
    540                     DonauSelector(
    541                         donauStatus = donauStatus,
    542                         showDialog = showSelector,
    543                         onSetup = { onSetupDonau(it) },
    544                         onDismiss = { showSelector = false },
    545                     )
    546                 }
    547 
    548                 val shouldSetDonau = choice.details is PaymentPossible
    549                         && useDonau
    550                         && donauStatus !is DonauStatus.Available
    551 
    552                 // CONFIRM BUTTON
    553                 if (selected) Button(
    554                     modifier = Modifier
    555                         .padding(top = 9.dp)
    556                         .fillMaxWidth(),
    557                     onClick = {
    558                         if (shouldSetDonau) {
    559                             showSelector = true
    560                         } else {
    561                             onConfirm(useDonau)
    562                         }
    563                     },
    564                     enabled = choice.details is PaymentPossible,
    565                 ) {
    566                     val tokenDetails = choice.details.tokenDetails
    567                     Text(
    568                         if (choice.details is PaymentPossible) {
    569                             if (shouldSetDonau) {
    570                                 stringResource(R.string.donau_select_button)
    571                             } else if (choice.details.amountEffective.isZero()) {
    572                                 stringResource(R.string.payment_button_confirm_tokens)
    573                             } else {
    574                                 stringResource(
    575                                     R.string.payment_button_confirm_amount,
    576                                     choice.details.amountEffective
    577                                 )
    578                             }
    579                         } else if (tokenDetails != null &&
    580                             tokenDetails.tokensAvailable
    581                             < tokenDetails.tokensRequested) {
    582                             stringResource(
    583                                 R.string.payment_tokens_insufficient,
    584                             )
    585                         } else {
    586                             stringResource(
    587                                 R.string.payment_balance_insufficient,
    588                             )
    589                         },
    590                     )
    591                 }
    592             }
    593         }
    594     }
    595 }
    596 
    597 @Composable
    598 fun PaymentInput(
    599     input: ContractInput,
    600     merchantBaseUrl: String,
    601     tokenFamilies: Map<String, ContractTokenFamily>,
    602     tokenAvailabilityDetails: PaymentTokenAvailabilityDetails?,
    603 ) {
    604     when (input) {
    605         is ContractInput.Token -> {
    606             // TODO: calculate from outside?
    607             // TODO: better error handling
    608             val family = tokenFamilies[input.tokenFamilySlug]
    609                 ?: error("no token family ${input.tokenFamilySlug}")
    610 
    611             val availability = tokenAvailabilityDetails?.perTokenFamily
    612                 ?.get(input.tokenFamilySlug)
    613 
    614             TokenCard(
    615                 name = family.name,
    616                 description = TalerUtils.getLocalizedString(
    617                     family.descriptionI18n,
    618                     family.description,
    619                 ),
    620                 count = input.count,
    621                 details = family.details,
    622                 merchantBaseUrl = merchantBaseUrl,
    623                 availabilityHint = availability?.causeHint,
    624             )
    625         }
    626     }
    627 }
    628 
    629 @Composable
    630 fun PaymentOutput(
    631     output: ContractOutput,
    632     tokenFamilies: Map<String, ContractTokenFamily>,
    633     merchantBaseUrl: String,
    634 ) {
    635     when (output) {
    636         is ContractOutput.Token -> {
    637             // TODO: calculate from outside?
    638             // TODO: better error handling
    639             val family = tokenFamilies[output.tokenFamilySlug]
    640                 ?: error("no token family for ${output.tokenFamilySlug}")
    641 
    642             TokenCard(
    643                 name = family.name,
    644                 description = TalerUtils.getLocalizedString(
    645                     family.descriptionI18n,
    646                     family.description,
    647                 ),
    648                 count = output.count,
    649                 details = family.details,
    650                 merchantBaseUrl = merchantBaseUrl,
    651             )
    652         }
    653 
    654         // TODO: ContractOutput.TaxReceipt
    655 
    656         else -> {}
    657     }
    658 }
    659 
    660 @Composable
    661 fun TokenCard(
    662     name: String,
    663     description: String,
    664     count: Int,
    665     details: ContractTokenDetails,
    666     merchantBaseUrl: String,
    667     availabilityHint: TokenAvailabilityHint? = null,
    668 ) {
    669     Card (
    670         modifier = Modifier
    671             .padding(vertical = 5.dp)
    672             .fillMaxWidth(),
    673     ) {
    674         Row(
    675             modifier = Modifier.padding(vertical = 8.dp),
    676             verticalAlignment = Alignment.CenterVertically
    677         ) {
    678             Box(
    679                 Modifier.padding(horizontal = 12.dp),
    680                 contentAlignment = Alignment.Center,
    681             ) {
    682                 when (details) {
    683                     is ContractTokenDetails.Discount -> Icon(
    684                         painterResource(R.drawable.ic_discounts),
    685                         contentDescription = stringResource(R.string.payment_token_discount),
    686                         tint = MaterialTheme.colorScheme.onSurfaceVariant,
    687                     )
    688 
    689                     is ContractTokenDetails.Subscription -> Icon(
    690                         Icons.Default.EventRepeat,
    691                         contentDescription = stringResource(R.string.payment_token_subscription),
    692                         tint = MaterialTheme.colorScheme.onSurfaceVariant,
    693                     )
    694                 }
    695             }
    696 
    697             Column(Modifier.weight(1f)) {
    698                 Text(
    699                     if (count > 1) {
    700                         stringResource(R.string.payment_product_price_quantity, count, name)
    701                     } else {
    702                         name
    703                     },
    704                     modifier = Modifier.padding(bottom = 4.dp),
    705                     style = MaterialTheme.typography.bodyMedium.copy(
    706                         color = MaterialTheme.colorScheme.onSurface,
    707                     ),
    708                 )
    709 
    710                 Text(
    711                     description,
    712                     style = MaterialTheme.typography.bodySmall.copy(
    713                         color = MaterialTheme.colorScheme.onSurfaceVariant
    714                     ),
    715                 )
    716             }
    717 
    718             Row(verticalAlignment = Alignment.CenterVertically) {
    719                 if (availabilityHint != null) {
    720                     TokenWarningTooltip(merchantBaseUrl, availabilityHint)
    721                 }
    722             }
    723         }
    724     }
    725 }
    726 
    727 @OptIn(ExperimentalMaterial3Api::class)
    728 @Composable
    729 fun TokenWarningTooltip(
    730     merchantBaseUrl: String,
    731     availabilityHint: TokenAvailabilityHint,
    732 ) {
    733     val icon = when(availabilityHint) {
    734         WalletTokensAvailableInsufficient -> Icons.Default.Error
    735         MerchantUntrusted -> Icons.Default.Error
    736         MerchantUnexpected -> Icons.Default.Warning
    737         else -> return
    738     }
    739 
    740     val tint = when(availabilityHint) {
    741         WalletTokensAvailableInsufficient -> MaterialTheme.colorScheme.error
    742         MerchantUntrusted -> MaterialTheme.colorScheme.error
    743         MerchantUnexpected -> MaterialTheme.colorScheme.onSurfaceVariant
    744     }
    745 
    746     val text = when(availabilityHint) {
    747         WalletTokensAvailableInsufficient -> stringResource(R.string.payment_tokens_insufficient)
    748         MerchantUntrusted -> stringResource(R.string.payment_tokens_untrusted, cleanExchange(merchantBaseUrl))
    749         MerchantUnexpected -> stringResource(R.string.payment_tokens_unexpected, cleanExchange(merchantBaseUrl))
    750     }
    751 
    752     val tooltipState = rememberTooltipState()
    753     val coroutineScope = rememberCoroutineScope()
    754 
    755     TooltipBox(
    756         modifier = Modifier,
    757         positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
    758         tooltip = { PlainTooltip { Text(text) } },
    759         state = tooltipState,
    760     ) {
    761         IconButton(onClick = {
    762             coroutineScope.launch {
    763                 tooltipState.show()
    764             }
    765         }) {
    766             Icon(
    767                 icon,
    768                 tint = tint,
    769                 contentDescription = when(availabilityHint) {
    770                     WalletTokensAvailableInsufficient -> stringResource(R.string.error)
    771                     MerchantUntrusted -> stringResource(R.string.error)
    772                     MerchantUnexpected -> stringResource(R.string.warning)
    773                 },
    774             )
    775         }
    776     }
    777 }
    778 
    779 @Composable
    780 fun PaymentFeeLabel(
    781     modifier: Modifier = Modifier,
    782     amountRaw: Amount,
    783     amountEffective: Amount,
    784 ) {
    785     if (amountEffective > amountRaw) {
    786         val fee = amountEffective - amountRaw
    787         Text(
    788             modifier = modifier,
    789             text = stringResource(
    790                 R.string.payment_fee,
    791                 fee.toString(showSymbol = false),
    792             ),
    793             style = MaterialTheme.typography.labelLarge.copy(
    794                 color = MaterialTheme.colorScheme.error,
    795             ),
    796         )
    797     }
    798 }
    799 
    800 private val contractTermsV0 = ContractTerms.V0(
    801     merchant = Merchant(
    802         name = "Demo Shop",
    803         logo = "data:image/png;base64,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",
    804     ),
    805     summary = "order summary",
    806     orderId = "102910291029",
    807     merchantBaseUrl = "https://backend.demo.taler.net/",
    808     amount = Amount.fromJSONString("KUDOS:1"),
    809     maxFee = Amount.fromJSONString("KUDOS:0"),
    810     products = listOf(
    811         ContractProduct(
    812             description = "something",
    813             price = Amount.fromJSONString("KUDOS:1"),
    814             image = "data:image/png;base64,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",
    815             quantity = 2,
    816         ),
    817 
    818         ContractProduct(
    819             description = "something",
    820             price = Amount.fromJSONString("KUDOS:1"),
    821             image = "data:image/png;base64,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",
    822             quantity = 1,
    823         ),
    824     ),
    825     exchanges = listOf(
    826         Exchange(url = "https://exchange.demo.taler.net/"),
    827         Exchange(url = "https://exchange.test.taler.net/"),
    828     ),
    829 )
    830 
    831 private val contractTermsV1 = ContractTerms.V1(
    832     merchant = contractTermsV0.merchant,
    833     summary = contractTermsV0.summary,
    834     orderId = contractTermsV0.orderId,
    835     merchantBaseUrl =  contractTermsV0.merchantBaseUrl,
    836     products = contractTermsV0.products,
    837     exchanges = contractTermsV0.exchanges,
    838 
    839     choices = listOf(
    840         ContractChoice(
    841             amount = Amount.fromJSONString("KUDOS:10"),
    842             description = "Tax-deductible movie pass discount",
    843             maxFee = Amount.fromJSONString("KUDOS:0"),
    844             inputs = listOf(
    845                 ContractInput.Token(tokenFamilySlug = "half-tax", count = 2),
    846                 ContractInput.Token(tokenFamilySlug = "movie-pass"),
    847             ),
    848             outputs = listOf(
    849                 ContractOutput.Token(tokenFamilySlug = "movie-pass"),
    850                 ContractOutput.TaxReceipt(
    851                     amount = Amount.fromJSONString("KUDOS:10"),
    852                     donauUrls = listOf("https://donau.test.taler.net/"),
    853                 )
    854             ),
    855         ),
    856 
    857         ContractChoice(
    858             amount = Amount.fromJSONString("KUDOS:200"),
    859             description = "Movie pass access renewal",
    860             maxFee = Amount.fromJSONString("KUDOS:0"),
    861             inputs = listOf(
    862                 ContractInput.Token(tokenFamilySlug = "movie-pass"),
    863             ),
    864             outputs = listOf(
    865                 ContractOutput.Token(tokenFamilySlug = "movie-pass"),
    866             ),
    867         ),
    868 
    869         ContractChoice(
    870             amount = Amount.fromJSONString("KUDOS:0"),
    871             description = "Movie pass access renewal",
    872             maxFee = Amount.fromJSONString("KUDOS:0"),
    873             inputs = listOf(
    874                 ContractInput.Token(tokenFamilySlug = "movie-pass"),
    875             ),
    876             outputs = listOf(
    877                 ContractOutput.Token(tokenFamilySlug = "movie-pass"),
    878             ),
    879         ),
    880     ),
    881 
    882     tokenFamilies = mapOf(
    883         "half-tax" to ContractTokenFamily(
    884             name = "half-tax",
    885             description = "50% discount",
    886             details = ContractTokenDetails.Discount,
    887             critical = true,
    888         ),
    889 
    890         "movie-pass" to ContractTokenFamily(
    891             name = "movie-pass",
    892             description = "Monthly movie pass",
    893             details = ContractTokenDetails.Subscription,
    894             critical = true,
    895         ),
    896     ),
    897 )
    898 
    899 @Preview
    900 @Composable
    901 fun PromptPaymentV0Preview() {
    902     TalerSurface {
    903         PromptPaymentComposable(PayStatus.Choices(
    904             transactionId = "txn:payment:2309203920",
    905             contractTerms = contractTermsV0,
    906             choices = listOf(
    907                 PayChoiceDetails(
    908                     choiceIndex = 0,
    909                     amountRaw = Amount.fromJSONString("KUDOS:10"),
    910                     inputs = listOf(),
    911                     outputs = listOf(),
    912                     details = PaymentPossible(
    913                         amountRaw = Amount.fromJSONString("KUDOS:10"),
    914                         amountEffective = Amount.fromJSONString("KUDOS:10.2"),
    915                     ),
    916                 )
    917             )
    918         ), { _, _ -> }, {}, {}, {}, { DonauStatus.Unavailable })
    919     }
    920 }
    921 
    922 @Preview
    923 @Composable
    924 fun PromptPaymentV1Preview() {
    925     TalerSurface {
    926         PromptPaymentComposable(PayStatus.Choices(
    927             transactionId = "txn:payment:2309203920",
    928             contractTerms = contractTermsV1,
    929             defaultChoiceIndex = 2,
    930             choices = listOf(
    931                 PayChoiceDetails(
    932                     choiceIndex = 0,
    933                     amountRaw = contractTermsV1.choices[0].amount,
    934                     description = "Tax-deductible movie pass discount",
    935                     inputs = contractTermsV1.choices[0].inputs,
    936                     outputs = contractTermsV1.choices[0].outputs,
    937                     details = PaymentPossible(
    938                         amountRaw = contractTermsV1.choices[0].amount,
    939                         amountEffective = contractTermsV1.choices[0].amount
    940                             .plus(Amount.fromJSONString("KUDOS:0.1")),
    941                         tokenDetails = PaymentTokenAvailabilityDetails(
    942                             tokensRequested = 3,
    943                             tokensAvailable = 2,
    944                             tokensUntrusted = 1,
    945                             tokensUnexpected = 1,
    946                             perTokenFamily = mapOf(
    947                                 "half-tax" to PaymentTokenAvailabilityDetails.PerTokenFamily(
    948                                     causeHint = MerchantUntrusted,
    949                                     requested = 2,
    950                                     available = 1,
    951                                     untrusted = 1,
    952                                     unexpected = 0
    953                                 ),
    954                                 "movie-pass" to PaymentTokenAvailabilityDetails.PerTokenFamily(
    955                                     causeHint = MerchantUnexpected,
    956                                     requested = 1,
    957                                     available = 1,
    958                                     untrusted = 0,
    959                                     unexpected = 1,
    960                                 ),
    961                             ),
    962                         ),
    963                     ),
    964                 ),
    965                 PayChoiceDetails(
    966                     choiceIndex = 1,
    967                     amountRaw = contractTermsV1.choices[1].amount,
    968                     description = "Movie pass access renewal",
    969                     inputs = contractTermsV1.choices[1].inputs,
    970                     outputs = contractTermsV1.choices[1].outputs,
    971                     details = InsufficientBalance(
    972                         amountRaw = contractTermsV1.choices[1].amount,
    973                     ),
    974                 ),
    975                 PayChoiceDetails(
    976                     choiceIndex = 2,
    977                     amountRaw = contractTermsV1.choices[2].amount,
    978                     description = "Movie pass access renewal",
    979                     inputs = contractTermsV1.choices[2].inputs,
    980                     outputs = contractTermsV1.choices[2].outputs,
    981                     details = PaymentPossible(
    982                         amountRaw = contractTermsV1.choices[2].amount,
    983                         amountEffective = contractTermsV1.choices[2].amount,
    984                     ),
    985                 ),
    986             )
    987         ), { _, _ -> }, {}, {}, {}, {
    988             DonauStatus.Mismatch(
    989                 donauInfo = DonauInfo("https://donau.test.taler.net/", "123"),
    990                 donauUrls = listOf(
    991                     "https://donau.demo.taler.net/",
    992                     "https://donau.head.taler.net/",
    993                 ),
    994             )
    995         })
    996     }
    997 }