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 }