taler-android

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

ProcessPaymentFragment.kt (17207B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2020 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.merchantpos.payment
     18 
     19 import android.os.Bundle
     20 import androidx.compose.foundation.layout.Arrangement
     21 import androidx.compose.foundation.layout.Box
     22 import androidx.compose.foundation.layout.BoxWithConstraints
     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.height
     29 import androidx.compose.foundation.layout.padding
     30 import androidx.compose.foundation.layout.size
     31 import androidx.compose.foundation.layout.statusBarsPadding
     32 import androidx.compose.foundation.layout.width
     33 import androidx.compose.material3.AlertDialog
     34 import androidx.compose.material3.Button
     35 import androidx.compose.material3.ButtonDefaults
     36 import androidx.compose.material3.CircularProgressIndicator
     37 import androidx.compose.material3.MaterialTheme
     38 import androidx.compose.material3.OutlinedButton
     39 import androidx.compose.material3.Text
     40 import androidx.compose.runtime.Composable
     41 import androidx.compose.runtime.LaunchedEffect
     42 import androidx.compose.runtime.getValue
     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.ComposeView
     49 import androidx.compose.ui.platform.LocalConfiguration
     50 import androidx.compose.ui.platform.ViewCompositionStrategy
     51 import androidx.compose.ui.res.painterResource
     52 import androidx.compose.ui.res.stringResource
     53 import androidx.compose.ui.text.style.TextAlign
     54 import androidx.compose.ui.unit.dp
     55 import androidx.fragment.app.Fragment
     56 import androidx.fragment.app.activityViewModels
     57 import androidx.compose.runtime.livedata.observeAsState
     58 import net.taler.lib.android.AnimatedQrCodeComposable
     59 import net.taler.lib.android.TalerNfcService.Companion.hasNfc
     60 import net.taler.lib.android.copyToClipBoard
     61 import net.taler.lib.android.shareText
     62 import net.taler.merchantpos.MainActivity
     63 import net.taler.merchantpos.MainViewModel
     64 import net.taler.merchantpos.PosDestination
     65 import net.taler.merchantpos.R
     66 import net.taler.merchantpos.compose.PosTheme
     67 import net.taler.merchantpos.showPosError
     68 
     69 class ProcessPaymentFragment : Fragment() {
     70 
     71     private val model: MainViewModel by activityViewModels()
     72     private val paymentManager by lazy { model.paymentManager }
     73 
     74     private var deviceHasNfc = false
     75 
     76     override fun onCreateView(
     77         inflater: android.view.LayoutInflater,
     78         container: android.view.ViewGroup?,
     79         savedInstanceState: Bundle?,
     80     ) = ComposeView(requireContext()).apply {
     81         setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
     82         setContent {
     83             val payment by paymentManager.payment.observeAsState()
     84             val deleteNeedsForce by paymentManager.deleteNeedsForce.observeAsState()
     85             payment?.let {
     86                 ProcessPaymentScreen(
     87                     payment = it,
     88                     deviceHasNfc = deviceHasNfc,
     89                     showForceDeleteDialog = deleteNeedsForce == true,
     90                     onCancel = {
     91                         if (it.claimed) {
     92                             paymentManager.tryDeleteOrder()
     93                         } else {
     94                             onPaymentCancel()
     95                         }
     96                     },
     97                     onForceDelete = ::onForceDeleteOrder,
     98                     onDismissForceDelete = {
     99                         paymentManager.clearDeleteNeedsForce()
    100                     },
    101                     onShare = { uri -> requireContext().shareText(uri) },
    102                     onCopy = { uri ->
    103                         copyToClipBoard(requireContext(), "Payment URI", uri)
    104                     },
    105                 )
    106             }
    107 
    108             // Navigate back on successful (non-force) delete
    109             LaunchedEffect(deleteNeedsForce) {
    110                 if (deleteNeedsForce == false) {
    111                     paymentManager.clearDeleteNeedsForce()
    112                     (requireActivity() as MainActivity).navigateBack()
    113                 }
    114             }
    115         }
    116     }
    117 
    118     override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) {
    119         deviceHasNfc = hasNfc(requireContext())
    120         paymentManager.payment.observe(viewLifecycleOwner) { payment ->
    121             onPaymentStateChanged(payment)
    122         }
    123     }
    124 
    125     override fun onDestroy() {
    126         super.onDestroy()
    127     }
    128 
    129     private fun onPaymentStateChanged(payment: Payment) {
    130         if (payment.error != null) {
    131             val (mainText, detailText) = getPaymentErrorDisplay(payment)
    132             requireActivity().showPosError(mainText, detailText)
    133             (requireActivity() as MainActivity).navigateBack()
    134             return
    135         }
    136         if (payment.paid) {
    137             model.orderManager.onOrderPaid(payment.order.id)
    138             (requireActivity() as MainActivity).navigateTo(PosDestination.PaymentSuccess)
    139         }
    140     }
    141 
    142     private fun onPaymentCancel() {
    143         paymentManager.cancelPayment()
    144         (requireActivity() as MainActivity).navigateBack()
    145     }
    146 
    147     private fun onForceDeleteOrder() {
    148         paymentManager.forceDeleteOrder()
    149         (requireActivity() as MainActivity).navigateBack()
    150     }
    151 
    152     private fun getPaymentErrorDisplay(payment: Payment): Pair<String, String> {
    153         val error = payment.error.orEmpty()
    154         if (payment.orderId != null) {
    155             return getString(R.string.error_payment) to error
    156         }
    157         val normalized = error.lowercase()
    158         return when {
    159             "inventory" in normalized ||
    160                 "stock" in normalized ||
    161                 "insufficient" in normalized ||
    162                 "sold out" in normalized ||
    163                 "out of stock" in normalized ->
    164                 getString(R.string.error_inventory_unavailable) to error
    165 
    166             else ->
    167                 getString(R.string.error_order_creation) to error
    168         }
    169     }
    170 }
    171 
    172 @Composable
    173 private fun ProcessPaymentScreen(
    174     payment: Payment,
    175     deviceHasNfc: Boolean,
    176     showForceDeleteDialog: Boolean,
    177     onCancel: () -> Unit,
    178     onForceDelete: () -> Unit,
    179     onDismissForceDelete: () -> Unit,
    180     onShare: (String) -> Unit,
    181     onCopy: (String) -> Unit,
    182 ) {
    183     if (showForceDeleteDialog) {
    184         AlertDialog(
    185             onDismissRequest = onDismissForceDelete,
    186             title = { Text(stringResource(R.string.force_delete_dialog_title)) },
    187             text = { Text(stringResource(R.string.force_delete_dialog_message)) },
    188             confirmButton = {
    189                 Button(
    190                     onClick = onForceDelete,
    191                     colors = ButtonDefaults.buttonColors(
    192                         containerColor = MaterialTheme.colorScheme.error,
    193                     ),
    194                 ) {
    195                     Text(stringResource(R.string.force_delete_dialog_confirm))
    196                 }
    197             },
    198             dismissButton = {
    199                 OutlinedButton(onClick = onDismissForceDelete) {
    200                     Text(stringResource(R.string.payment_cancel))
    201                 }
    202             },
    203         )
    204     }
    205 
    206     PosTheme {
    207         val introText = if (payment.claimed) {
    208             stringResource(R.string.payment_claimed)
    209         } else if (deviceHasNfc && payment.talerPayUri != null) {
    210             stringResource(R.string.payment_intro_nfc)
    211         } else {
    212             stringResource(R.string.payment_intro)
    213         }
    214         val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720
    215 
    216         if (isTabletLayout) {
    217             TabletProcessPaymentScreen(payment, introText, onCancel, onShare, onCopy)
    218         } else {
    219             PhoneProcessPaymentScreen(payment, introText, onCancel, onShare, onCopy)
    220         }
    221     }
    222 }
    223 
    224 @Composable
    225 private fun TabletProcessPaymentScreen(
    226     payment: Payment,
    227     introText: String,
    228     onCancel: () -> Unit,
    229     onShare: (String) -> Unit,
    230     onCopy: (String) -> Unit,
    231 ) {
    232     Row(
    233         modifier = Modifier
    234             .fillMaxSize()
    235             .statusBarsPadding()
    236             .padding(16.dp),
    237         horizontalArrangement = Arrangement.spacedBy(16.dp),
    238     ) {
    239         Column(
    240             modifier = Modifier
    241                 .weight(0.54f)
    242                 .fillMaxSize(),
    243             verticalArrangement = Arrangement.spacedBy(12.dp),
    244             horizontalAlignment = Alignment.CenterHorizontally,
    245         ) {
    246             Box(
    247                 modifier = Modifier
    248                     .fillMaxWidth()
    249                     .weight(1f),
    250                 contentAlignment = Alignment.Center,
    251             ) {
    252                 if (!payment.claimed) {
    253                     BoxWithConstraints(
    254                         modifier = Modifier
    255                             .fillMaxSize()
    256                             .padding(12.dp),
    257                         contentAlignment = Alignment.Center,
    258                     ) {
    259                         val payUri = payment.talerPayUri
    260                         val qrSize = minOf(maxWidth, maxHeight)
    261                         Box(
    262                             modifier = Modifier.size(qrSize),
    263                             contentAlignment = Alignment.Center,
    264                         ) {
    265                             if (payUri == null) {
    266                                 CircularProgressIndicator()
    267                             } else {
    268                                 AnimatedQrCodeComposable(
    269                                     link = payUri,
    270                                     logoPainter = painterResource(R.drawable.ic_taler_logo_qr),
    271                                     modifier = Modifier.fillMaxSize(),
    272                                 )
    273                             }
    274                         }
    275                     }
    276                 }
    277             }
    278 
    279             if (!payment.claimed) payment.talerPayUri?.let { payUri ->
    280                 Row(
    281                     modifier = Modifier.fillMaxWidth(),
    282                     horizontalArrangement = Arrangement.spacedBy(12.dp),
    283                 ) {
    284                     Button(onClick = { onShare(payUri) }, modifier = Modifier.weight(1f)) {
    285                         Text(stringResource(R.string.share))
    286                     }
    287                     Button(onClick = { onCopy(payUri) }, modifier = Modifier.weight(1f)) {
    288                         Text(stringResource(R.string.copy))
    289                     }
    290                 }
    291             }
    292         }
    293 
    294         Column(
    295             modifier = Modifier
    296                 .weight(0.46f)
    297                 .fillMaxSize()
    298                 .padding(horizontal = 8.dp),
    299             verticalArrangement = Arrangement.spacedBy(16.dp),
    300             horizontalAlignment = Alignment.CenterHorizontally,
    301         ) {
    302             Text(
    303                 text = introText,
    304                 style = MaterialTheme.typography.headlineSmall,
    305                 textAlign = TextAlign.Center,
    306             )
    307             Text(
    308                 text = payment.order.total.toString(),
    309                 style = MaterialTheme.typography.headlineMedium,
    310                 textAlign = TextAlign.Center,
    311             )
    312             payment.orderId?.let {
    313                 Text(
    314                     text = stringResource(R.string.payment_order_id, it),
    315                     style = MaterialTheme.typography.bodyLarge,
    316                     textAlign = TextAlign.Center,
    317                 )
    318             }
    319             Spacer(modifier = Modifier.weight(1f))
    320             OutlinedButton(
    321                 onClick = onCancel,
    322                 modifier = Modifier.fillMaxWidth(),
    323                 colors = ButtonDefaults.outlinedButtonColors(
    324                     containerColor = MaterialTheme.colorScheme.error,
    325                     contentColor = MaterialTheme.colorScheme.onError,
    326                 ),
    327             ) {
    328                 Text(stringResource(R.string.payment_cancel))
    329             }
    330         }
    331     }
    332 }
    333 
    334 @Composable
    335 private fun PhoneProcessPaymentScreen(
    336     payment: Payment,
    337     introText: String,
    338     onCancel: () -> Unit,
    339     onShare: (String) -> Unit,
    340     onCopy: (String) -> Unit,
    341 ) {
    342     Row(
    343         modifier = Modifier
    344             .fillMaxSize()
    345             .statusBarsPadding()
    346             .padding(16.dp),
    347         horizontalArrangement = Arrangement.spacedBy(16.dp),
    348     ) {
    349         Column(
    350             modifier = Modifier
    351                 .weight(0.5f)
    352                 .fillMaxSize(),
    353             verticalArrangement = Arrangement.Center,
    354             horizontalAlignment = Alignment.CenterHorizontally,
    355         ) {
    356             if (!payment.claimed) {
    357                 Box(
    358                     modifier = Modifier
    359                         .fillMaxWidth()
    360                         .weight(1f),
    361                     contentAlignment = Alignment.Center,
    362                 ) {
    363                     BoxWithConstraints(
    364                         modifier = Modifier
    365                             .fillMaxSize()
    366                             .padding(12.dp),
    367                         contentAlignment = Alignment.Center,
    368                     ) {
    369                         val payUri = payment.talerPayUri
    370                         val qrSize = minOf(maxWidth, maxHeight)
    371                         Box(
    372                             modifier = Modifier.size(qrSize),
    373                             contentAlignment = Alignment.Center,
    374                         ) {
    375                             if (payUri == null) {
    376                                 CircularProgressIndicator()
    377                             } else {
    378                                 AnimatedQrCodeComposable(
    379                                     link = payUri,
    380                                     logoPainter = painterResource(R.drawable.ic_taler_logo_qr),
    381                                     modifier = Modifier.fillMaxSize(),
    382                                 )
    383                             }
    384                         }
    385                     }
    386                 }
    387             }
    388         }
    389 
    390         Column(
    391             modifier = Modifier
    392                 .weight(0.5f)
    393                 .fillMaxSize()
    394                 .padding(horizontal = 8.dp),
    395             verticalArrangement = Arrangement.Center,
    396             horizontalAlignment = Alignment.CenterHorizontally,
    397         ) {
    398             Text(
    399                 text = introText,
    400                 style = MaterialTheme.typography.headlineSmall,
    401                 textAlign = TextAlign.Center,
    402             )
    403             Text(
    404                 text = payment.order.total.toString(),
    405                 style = MaterialTheme.typography.headlineMedium,
    406                 textAlign = TextAlign.Center,
    407             )
    408             payment.orderId?.let {
    409                 Text(
    410                     text = stringResource(R.string.payment_order_id, it),
    411                     style = MaterialTheme.typography.bodyLarge,
    412                     textAlign = TextAlign.Center,
    413                 )
    414             }
    415             Spacer(modifier = Modifier.height(8.dp))
    416             payment.talerPayUri?.let { payUri ->
    417                 Row(
    418                     modifier = Modifier.fillMaxWidth(),
    419                     horizontalArrangement = Arrangement.spacedBy(8.dp),
    420                 ) {
    421                     Button(
    422                         onClick = { onShare(payUri) },
    423                         modifier = Modifier.weight(1f),
    424                     ) {
    425                         Text(stringResource(R.string.share))
    426                     }
    427                     OutlinedButton(
    428                         onClick = { onCopy(payUri) },
    429                         modifier = Modifier.weight(1f),
    430                     ) {
    431                         Text(stringResource(R.string.copy))
    432                     }
    433                 }
    434             }
    435             OutlinedButton(
    436                 onClick = onCancel,
    437                 modifier = Modifier.fillMaxWidth(),
    438                 colors = ButtonDefaults.outlinedButtonColors(
    439                     containerColor = MaterialTheme.colorScheme.error,
    440                     contentColor = MaterialTheme.colorScheme.onError,
    441                 ),
    442             ) {
    443                 Text(stringResource(R.string.payment_cancel))
    444             }
    445         }
    446     }
    447 }
    448 
    449 @Composable
    450 internal fun PaymentScreenContent(payment: Payment) {
    451     ProcessPaymentScreen(
    452         payment = payment,
    453         deviceHasNfc = false,
    454         showForceDeleteDialog = false,
    455         onCancel = {},
    456         onForceDelete = {},
    457         onDismissForceDelete = {},
    458         onShare = {},
    459         onCopy = {},
    460     )
    461 }