taler-android

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

HistoryFragment.kt (16855B)


      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.history
     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.Column
     23 import androidx.compose.foundation.layout.Row
     24 import androidx.compose.foundation.layout.fillMaxSize
     25 import androidx.compose.foundation.layout.fillMaxWidth
     26 import androidx.compose.foundation.layout.padding
     27 import androidx.compose.foundation.layout.statusBarsPadding
     28 import androidx.compose.foundation.lazy.LazyColumn
     29 import androidx.compose.foundation.lazy.items
     30 import androidx.compose.foundation.lazy.rememberLazyListState
     31 import androidx.compose.material3.AlertDialog
     32 import androidx.compose.material3.Button
     33 import androidx.compose.material3.ButtonDefaults
     34 import androidx.compose.material3.Card
     35 import androidx.compose.material3.CircularProgressIndicator
     36 import androidx.compose.material3.MaterialTheme
     37 import androidx.compose.material3.OutlinedButton
     38 import androidx.compose.material3.Surface
     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.snapshotFlow
     44 import androidx.compose.ui.Modifier
     45 import androidx.compose.ui.platform.ComposeView
     46 import androidx.compose.ui.platform.ViewCompositionStrategy
     47 import androidx.compose.ui.res.colorResource
     48 import androidx.compose.ui.res.stringResource
     49 import androidx.compose.ui.unit.dp
     50 import androidx.fragment.app.Fragment
     51 import androidx.fragment.app.activityViewModels
     52 import androidx.compose.runtime.livedata.observeAsState
     53 import kotlinx.coroutines.flow.collectLatest
     54 import net.taler.lib.android.toRelativeTime
     55 import net.taler.merchantpos.MainActivity
     56 import net.taler.merchantlib.OrderHistoryEntry
     57 import net.taler.merchantpos.MainViewModel
     58 import net.taler.merchantpos.PosDestination
     59 import net.taler.merchantpos.R
     60 import net.taler.merchantpos.compose.PosTheme
     61 import net.taler.merchantpos.payment.Payment
     62 import net.taler.merchantpos.showPosError
     63 
     64 internal interface HistoryActionListener {
     65     fun onRefundClicked(item: OrderHistoryEntry)
     66     fun onDeleteClicked(item: OrderHistoryEntry)
     67     fun onShowPaymentClicked(item: OrderHistoryEntry)
     68     fun onShowRefundClicked(item: OrderHistoryEntry)
     69 }
     70 
     71 class HistoryFragment : Fragment(), HistoryActionListener {
     72 
     73     private val model: MainViewModel by activityViewModels()
     74     private val historyManager by lazy { model.historyManager }
     75     private val refundManager by lazy { model.refundManager }
     76 
     77     override fun onCreateView(
     78         inflater: android.view.LayoutInflater,
     79         container: android.view.ViewGroup?,
     80         savedInstanceState: Bundle?,
     81     ) = ComposeView(requireContext()).apply {
     82         setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
     83         setContent {
     84             val isLoading by historyManager.isLoading.observeAsState(false)
     85             val isLoadingMore by historyManager.isLoadingMore.observeAsState(false)
     86             val result by historyManager.items.observeAsState()
     87             val pendingRefundOrderId by refundManager.pendingRefundOrderId.observeAsState()
     88             val activePayment by model.paymentManager.payment.observeAsState()
     89             val forceDeleteOrderId by historyManager.forceDeleteOrderId.observeAsState()
     90             HistoryScreen(
     91                 isLoading = isLoading,
     92                 isLoadingMore = isLoadingMore,
     93                 result = result,
     94                 pendingRefundOrderId = pendingRefundOrderId,
     95                 activePayment = activePayment,
     96                 onRefresh = { historyManager.fetchHistory() },
     97                 onLoadMore = historyManager::loadMoreHistoryIfNeeded,
     98                 onRefundClicked = ::onRefundClicked,
     99                 onDeleteClicked = ::onDeleteClicked,
    100                 onShowPaymentClicked = ::onShowPaymentClicked,
    101                 onShowRefundClicked = ::onShowRefundClicked,
    102             )
    103             forceDeleteOrderId?.let { orderId ->
    104                 ForceDeleteOrderDialog(
    105                     onConfirm = { historyManager.forceDeleteOrder(orderId) },
    106                     onDismiss = { historyManager.clearForceDeletePrompt() },
    107                 )
    108             }
    109         }
    110     }
    111 
    112     override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) {
    113         super.onViewCreated(view, savedInstanceState)
    114         historyManager.error.observe(viewLifecycleOwner) { error ->
    115             if (error != null) {
    116                 requireActivity().showPosError(error.mainResId, error.msg)
    117                 historyManager.clearError()
    118             }
    119         }
    120     }
    121 
    122     override fun onStart() {
    123         super.onStart()
    124         if (model.configManager.merchantConfig?.baseUrl == null) {
    125             (requireActivity() as MainActivity).navigateTo(PosDestination.Config)
    126         } else {
    127             historyManager.fetchHistory()
    128         }
    129     }
    130 
    131     override fun onRefundClicked(item: OrderHistoryEntry) {
    132         refundManager.startRefund(item)
    133         (requireActivity() as MainActivity).navigateTo(PosDestination.Refund)
    134     }
    135 
    136     override fun onDeleteClicked(item: OrderHistoryEntry) {
    137         historyManager.deleteOrder(item.orderId)
    138     }
    139 
    140     override fun onShowPaymentClicked(item: OrderHistoryEntry) {
    141         model.paymentManager.resumePayment(item)
    142         (requireActivity() as MainActivity).navigateTo(PosDestination.ProcessPayment)
    143     }
    144 
    145     override fun onShowRefundClicked(item: OrderHistoryEntry) {
    146         if (refundManager.resumeRefund(item)) {
    147             (requireActivity() as MainActivity).navigateTo(PosDestination.RefundUri)
    148         } else {
    149             requireActivity().showPosError(R.string.refund_state_missing)
    150             historyManager.fetchHistory()
    151         }
    152     }
    153 }
    154 
    155 @Composable
    156 private fun HistoryScreen(
    157     isLoading: Boolean,
    158     isLoadingMore: Boolean,
    159     result: HistoryResult?,
    160     pendingRefundOrderId: String?,
    161     activePayment: Payment?,
    162     onRefresh: () -> Unit,
    163     onLoadMore: (Int) -> Unit,
    164     onRefundClicked: (OrderHistoryEntry) -> Unit,
    165     onDeleteClicked: (OrderHistoryEntry) -> Unit,
    166     onShowPaymentClicked: (OrderHistoryEntry) -> Unit,
    167     onShowRefundClicked: (OrderHistoryEntry) -> Unit,
    168 ) {
    169     PosTheme {
    170         val listState = rememberLazyListState()
    171         val historyItemKeys = (result as? HistoryResult.Success)?.items?.map { it.orderId }.orEmpty()
    172         val firstHistoryItemKey = historyItemKeys.firstOrNull()
    173 
    174         LaunchedEffect(firstHistoryItemKey) {
    175             if (firstHistoryItemKey != null) {
    176                 listState.scrollToItem(0)
    177             }
    178         }
    179 
    180         LaunchedEffect(listState, historyItemKeys.size) {
    181             snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
    182                 .collectLatest { index ->
    183                     if (index != null) onLoadMore(index)
    184                 }
    185         }
    186 
    187         Column(
    188             modifier = Modifier
    189                 .fillMaxSize()
    190                 .statusBarsPadding()
    191                 .padding(16.dp),
    192             verticalArrangement = Arrangement.spacedBy(12.dp),
    193         ) {
    194             if (isLoading && result !is HistoryResult.Success) {
    195                 CircularProgressIndicator()
    196             }
    197 
    198             when (result) {
    199                 is HistoryResult.Success -> {
    200                     LazyColumn(
    201                         state = listState,
    202                         verticalArrangement = Arrangement.spacedBy(12.dp),
    203                     ) {
    204                         items(result.items, key = { it.orderId }) { item ->
    205                             HistoryItemCard(
    206                                 item = item,
    207                                 hasPendingRefund = pendingRefundOrderId == item.orderId,
    208                                 activePayment = activePayment?.takeIf { it.orderId == item.orderId },
    209                                 onRefundClicked = { onRefundClicked(item) },
    210                                 onDeleteClicked = { onDeleteClicked(item) },
    211                                 onShowPaymentClicked = { onShowPaymentClicked(item) },
    212                                 onShowRefundClicked = { onShowRefundClicked(item) },
    213                             )
    214                         }
    215                         if (isLoadingMore) {
    216                             item(key = "loading-more") {
    217                                 Box(
    218                                     modifier = Modifier
    219                                         .fillMaxWidth()
    220                                         .padding(vertical = 8.dp),
    221                                 ) {
    222                                     CircularProgressIndicator(
    223                                         modifier = Modifier.align(androidx.compose.ui.Alignment.Center),
    224                                     )
    225                                 }
    226                             }
    227                         }
    228                     }
    229                 }
    230 
    231                 null -> Unit
    232             }
    233         }
    234     }
    235 }
    236 
    237 @Composable
    238 private fun HistoryItemCard(
    239     item: OrderHistoryEntry,
    240     hasPendingRefund: Boolean,
    241     activePayment: Payment?,
    242     onRefundClicked: () -> Unit,
    243     onDeleteClicked: () -> Unit,
    244     onShowPaymentClicked: () -> Unit,
    245     onShowRefundClicked: () -> Unit,
    246 ) {
    247     val status = when {
    248         item.hasPendingRefund -> HistoryStatus.RefundPending
    249         item.hasRefund -> HistoryStatus.Refunded
    250         hasPendingRefund -> HistoryStatus.RefundPending
    251         activePayment?.paid == true -> HistoryStatus.Paid
    252         activePayment?.claimed == true -> HistoryStatus.PaymentClaimed
    253         activePayment?.orderId == item.orderId && activePayment.error == null -> HistoryStatus.PaymentPending
    254         item.paid -> HistoryStatus.Paid
    255         else -> HistoryStatus.Unpaid
    256     }
    257 
    258     Card {
    259         Column(
    260             modifier = Modifier
    261                 .fillMaxWidth()
    262                 .padding(16.dp),
    263             verticalArrangement = Arrangement.spacedBy(8.dp),
    264         ) {
    265             Row(
    266                 modifier = Modifier.fillMaxWidth(),
    267                 horizontalArrangement = Arrangement.spacedBy(12.dp),
    268             ) {
    269                 Column(
    270                     modifier = Modifier.weight(1f),
    271                     verticalArrangement = Arrangement.spacedBy(2.dp),
    272                 ) {
    273                     Text(
    274                         text = item.summary,
    275                         style = MaterialTheme.typography.titleMedium,
    276                     )
    277                     Text(
    278                         text = item.amount.toString(),
    279                         style = MaterialTheme.typography.bodyLarge,
    280                     )
    281                 }
    282                 Column(
    283                     horizontalAlignment = androidx.compose.ui.Alignment.End,
    284                     verticalArrangement = Arrangement.spacedBy(6.dp),
    285                 ) {
    286                     HistoryStatusBadge(status = status)
    287                     if (item.hasRefund && item.refundAmount != null) {
    288                         HistoryAmountBadge(
    289                             amount = item.refundAmount.toString(),
    290                             status = status,
    291                         )
    292                     }
    293                 }
    294             }
    295             Text(
    296                 item.timestamp.ms.toRelativeTime(androidx.compose.ui.platform.LocalContext.current).toString(),
    297                 style = MaterialTheme.typography.bodyMedium,
    298             )
    299             Text(stringResource(R.string.history_ref_no, item.orderId))
    300             Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    301                 if (item.hasPendingRefund || hasPendingRefund) {
    302                     Button(onClick = onShowRefundClicked) {
    303                         Text(stringResource(R.string.history_show_refund))
    304                     }
    305                 } else if (item.refundable) {
    306                     Button(onClick = onRefundClicked) {
    307                         Text(stringResource(R.string.history_refund))
    308                     }
    309                 } else if (!item.paid) {
    310                     Button(onClick = onShowPaymentClicked) {
    311                         Text(stringResource(R.string.history_show_payment))
    312                     }
    313                     OutlinedButton(onClick = onDeleteClicked) {
    314                         Text(stringResource(R.string.order_delete))
    315                     }
    316                 }
    317             }
    318         }
    319     }
    320 }
    321 
    322 private enum class HistoryStatus(
    323     val labelResId: Int,
    324 ) {
    325     Unpaid(R.string.history_status_unpaid),
    326     Paid(R.string.history_status_paid),
    327     PaymentPending(R.string.history_status_payment_pending),
    328     PaymentClaimed(R.string.history_status_payment_claimed),
    329     RefundPending(R.string.history_status_refund_pending),
    330     Refunded(R.string.history_status_refunded),
    331 }
    332 
    333 @Composable
    334 private fun HistoryStatusBadge(status: HistoryStatus) {
    335     val colors = when (status) {
    336         HistoryStatus.Unpaid -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
    337         HistoryStatus.Paid -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
    338         HistoryStatus.PaymentPending -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer
    339         HistoryStatus.PaymentClaimed -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
    340         HistoryStatus.RefundPending -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer
    341         HistoryStatus.Refunded -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
    342     }
    343     Surface(
    344         color = colors.first,
    345         contentColor = colors.second,
    346         shape = MaterialTheme.shapes.small,
    347     ) {
    348         Text(
    349             text = stringResource(status.labelResId),
    350             modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
    351             style = MaterialTheme.typography.labelLarge,
    352         )
    353     }
    354 }
    355 
    356 @Composable
    357 private fun HistoryAmountBadge(
    358     amount: String,
    359     status: HistoryStatus,
    360 ) {
    361     val colors = when (status) {
    362         HistoryStatus.RefundPending -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer
    363         HistoryStatus.Refunded -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
    364         else -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
    365     }
    366     Surface(
    367         color = colors.first,
    368         contentColor = colors.second,
    369         shape = MaterialTheme.shapes.small,
    370     ) {
    371         Text(
    372             text = amount,
    373             modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
    374             style = MaterialTheme.typography.labelLarge,
    375         )
    376     }
    377 }
    378 
    379 @Composable
    380 private fun ForceDeleteOrderDialog(
    381     onConfirm: () -> Unit,
    382     onDismiss: () -> Unit,
    383 ) {
    384     AlertDialog(
    385         onDismissRequest = onDismiss,
    386         title = { Text(stringResource(R.string.force_delete_dialog_title)) },
    387         text = { Text(stringResource(R.string.force_delete_dialog_message)) },
    388         confirmButton = {
    389             Button(
    390                 onClick = onConfirm,
    391                 colors = ButtonDefaults.buttonColors(
    392                     containerColor = MaterialTheme.colorScheme.error,
    393                 ),
    394             ) {
    395                 Text(stringResource(R.string.force_delete_dialog_confirm))
    396             }
    397         },
    398         dismissButton = {
    399             OutlinedButton(onClick = onDismiss) {
    400                 Text(stringResource(R.string.payment_cancel))
    401             }
    402         },
    403     )
    404 }
    405 
    406 @Composable
    407 internal fun HistoryScreenContent(
    408     items: List<OrderHistoryEntry>,
    409 ) {
    410     HistoryScreen(
    411         isLoading = false,
    412         isLoadingMore = false,
    413         result = HistoryResult.Success(items),
    414         pendingRefundOrderId = null,
    415         activePayment = null,
    416         onRefresh = {},
    417         onLoadMore = {},
    418         onRefundClicked = {},
    419         onDeleteClicked = {},
    420         onShowPaymentClicked = {},
    421         onShowRefundClicked = {},
    422     )
    423 }