TransactionsComposable.kt (21699B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2024 Taler Systems S.A. 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.wallet.transactions 18 19 import androidx.compose.animation.animateContentSize 20 import androidx.compose.foundation.basicMarquee 21 import androidx.compose.foundation.clickable 22 import androidx.compose.foundation.combinedClickable 23 import androidx.compose.foundation.layout.Box 24 import androidx.compose.foundation.layout.Column 25 import androidx.compose.foundation.layout.IntrinsicSize 26 import androidx.compose.foundation.layout.PaddingValues 27 import androidx.compose.foundation.layout.Row 28 import androidx.compose.foundation.layout.consumeWindowInsets 29 import androidx.compose.foundation.layout.defaultMinSize 30 import androidx.compose.foundation.layout.fillMaxHeight 31 import androidx.compose.foundation.layout.fillMaxSize 32 import androidx.compose.foundation.layout.fillMaxWidth 33 import androidx.compose.foundation.layout.height 34 import androidx.compose.foundation.layout.padding 35 import androidx.compose.foundation.lazy.LazyColumn 36 import androidx.compose.foundation.lazy.items 37 import androidx.compose.material.icons.Icons 38 import androidx.compose.material.icons.filled.ArrowDropDown 39 import androidx.compose.material.icons.rounded.CheckCircle 40 import androidx.compose.material.icons.rounded.RadioButtonUnchecked 41 import androidx.compose.material3.Badge 42 import androidx.compose.material3.HorizontalDivider 43 import androidx.compose.material3.Icon 44 import androidx.compose.material3.ListItem 45 import androidx.compose.material3.ListItemDefaults 46 import androidx.compose.material3.MaterialTheme 47 import androidx.compose.material3.OutlinedCard 48 import androidx.compose.material3.ProvideTextStyle 49 import androidx.compose.material3.Text 50 import androidx.compose.runtime.Composable 51 import androidx.compose.ui.Alignment 52 import androidx.compose.ui.Alignment.Companion.Center 53 import androidx.compose.ui.Modifier 54 import androidx.compose.ui.graphics.Color 55 import androidx.compose.ui.hapticfeedback.HapticFeedbackType 56 import androidx.compose.ui.platform.LocalContext 57 import androidx.compose.ui.platform.LocalHapticFeedback 58 import androidx.compose.ui.res.painterResource 59 import androidx.compose.ui.res.stringResource 60 import androidx.compose.ui.text.font.FontWeight 61 import androidx.compose.ui.text.style.TextAlign 62 import androidx.compose.ui.text.style.TextOverflow 63 import androidx.compose.ui.tooling.preview.Preview 64 import androidx.compose.ui.unit.dp 65 import net.taler.common.Amount 66 import net.taler.common.CurrencySpecification 67 import net.taler.common.Timestamp 68 import net.taler.lib.android.toRelativeTime 69 import net.taler.wallet.R 70 import net.taler.wallet.backend.TalerErrorCode 71 import net.taler.wallet.backend.TalerErrorInfo 72 import net.taler.wallet.balances.BalanceItem 73 import net.taler.wallet.balances.ScopeInfo.Exchange 74 import net.taler.wallet.cleanExchange 75 import net.taler.wallet.compose.Banner 76 import net.taler.wallet.compose.LoadingScreen 77 import net.taler.wallet.compose.TalerSurface 78 import net.taler.wallet.compose.cardPaddings 79 import net.taler.wallet.main.ViewMode 80 import net.taler.wallet.transactions.AmountType.Negative 81 import net.taler.wallet.transactions.AmountType.Neutral 82 import net.taler.wallet.transactions.AmountType.Positive 83 import net.taler.wallet.transactions.TransactionAction.Abort 84 import net.taler.wallet.transactions.TransactionAction.Retry 85 import net.taler.wallet.transactions.TransactionAction.Suspend 86 import net.taler.wallet.transactions.TransactionMajorState.Aborted 87 import net.taler.wallet.transactions.TransactionMajorState.Aborting 88 import net.taler.wallet.transactions.TransactionMajorState.Dialog 89 import net.taler.wallet.transactions.TransactionMajorState.Done 90 import net.taler.wallet.transactions.TransactionMajorState.Failed 91 import net.taler.wallet.transactions.TransactionMajorState.Pending 92 import net.taler.wallet.transactions.TransactionMinorState.BalanceKycRequired 93 import net.taler.wallet.transactions.TransactionMinorState.BankConfirmTransfer 94 import net.taler.wallet.transactions.TransactionMinorState.KycAuthRequired 95 import net.taler.wallet.transactions.TransactionMinorState.KycInit 96 import net.taler.wallet.transactions.TransactionMinorState.KycRequired 97 import net.taler.wallet.transactions.TransactionMinorState.Repurchase 98 import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal 99 import net.taler.wallet.transactions.TransactionsResult.Error 100 import net.taler.wallet.transactions.TransactionsResult.None 101 import net.taler.wallet.transactions.TransactionsResult.Success 102 import net.taler.wallet.ui.theme.TalerTheme 103 104 @Composable 105 fun TransactionsComposable( 106 innerPadding: PaddingValues, 107 viewMode: ViewMode.Transactions, 108 balance: BalanceItem, 109 txResult: TransactionsResult, 110 onTransactionClick: (tx: Transaction) -> Unit, 111 onShowBalancesClicked: () -> Unit, 112 selectionMode: Boolean, 113 selectedItems: MutableList<String>, 114 onToggleSelection: (String) -> Unit, 115 ) { 116 Column(Modifier.fillMaxSize()) { 117 LazyColumn( 118 Modifier 119 .weight(1f) 120 .consumeWindowInsets(innerPadding), 121 contentPadding = innerPadding, 122 ) { 123 item { 124 TransactionsHeader( 125 balance = balance, 126 onShowBalancesClicked = onShowBalancesClicked, 127 ) 128 } 129 130 when (viewMode.stateFilter) { 131 Nonfinal -> item { 132 Banner(Modifier.padding(bottom = 6.dp)) { 133 Text( 134 modifier = Modifier 135 .fillMaxWidth(), 136 text = stringResource(R.string.transactions_filter_nonfinal), 137 style = MaterialTheme.typography.labelLarge, 138 textAlign = TextAlign.Center, 139 ) 140 } 141 } 142 else -> {} 143 } 144 145 val placeholderPadding = PaddingValues(vertical = 50.dp) 146 when (txResult) { 147 is Success -> if (txResult.transactions.isEmpty()) item { 148 Box(Modifier.padding(placeholderPadding)) { 149 EmptyTransactionsComposable() 150 } 151 } else { 152 items(txResult.transactions, key = { it.transactionId }) { tx -> 153 val isSelected = selectedItems.contains(tx.transactionId) 154 155 TransactionRow( 156 tx, balance.available.spec, 157 isSelected = isSelected, 158 selectionMode = selectionMode, 159 onTransactionClick = { 160 if (selectionMode) { 161 onToggleSelection(tx.transactionId) 162 } else { 163 onTransactionClick(tx) 164 } 165 }, 166 onTransactionSelect = { 167 onToggleSelection(tx.transactionId) 168 }, 169 ) 170 } 171 } 172 173 is None -> item { 174 Box(Modifier.padding(placeholderPadding)) { 175 LoadingScreen() 176 } 177 } 178 is Error -> item { 179 Box(Modifier.padding(placeholderPadding)) { 180 ErrorTransactionsComposable(txResult.error) 181 } 182 } 183 } 184 } 185 } 186 } 187 188 @Composable 189 fun EmptyTransactionsComposable() { 190 Box( 191 modifier = Modifier.fillMaxSize(), 192 contentAlignment = Center, 193 ) { 194 Text( 195 stringResource(R.string.transactions_empty), 196 textAlign = TextAlign.Center, 197 style = MaterialTheme.typography.bodyMedium, 198 ) 199 } 200 } 201 202 @Composable 203 fun ErrorTransactionsComposable(error: TalerErrorInfo) { 204 Text( 205 text = stringResource(R.string.transactions_error, error.userFacingMsg), 206 style = MaterialTheme.typography.titleLarge, 207 color = MaterialTheme.colorScheme.error, 208 ) 209 } 210 211 @Composable 212 fun TransactionsHeader( 213 balance: BalanceItem, 214 onShowBalancesClicked: () -> Unit, 215 ) { 216 Row( 217 modifier = Modifier.fillMaxWidth(), 218 verticalAlignment = Alignment.CenterVertically, 219 ) { 220 OutlinedCard( 221 Modifier 222 .weight(1f) 223 .cardPaddings() 224 .clickable { onShowBalancesClicked() }, 225 ) { 226 ListItem( 227 modifier = Modifier.animateContentSize(), 228 headlineContent = { 229 Text( 230 getHeaderCurrency(balance, balance.available.spec), 231 modifier = Modifier.weight(1f, fill = false), 232 style = MaterialTheme.typography.titleMedium, 233 overflow = TextOverflow.Ellipsis, 234 maxLines = 1, 235 ) 236 }, 237 supportingContent = { 238 if (balance.scopeInfo is Exchange) { 239 Text( 240 cleanExchange(balance.scopeInfo.url), 241 modifier = Modifier 242 .padding(top = 3.dp) 243 .basicMarquee(), 244 style = MaterialTheme.typography.bodySmall, 245 ) 246 } 247 }, 248 trailingContent = { 249 Icon(Icons.Default.ArrowDropDown, contentDescription = null) 250 } 251 ) 252 } 253 254 Column( 255 modifier = Modifier.padding(16.dp), 256 horizontalAlignment = Alignment.End, 257 ) { 258 Text( 259 stringResource(R.string.transactions_balance), 260 modifier = Modifier.padding(bottom = 6.dp), 261 style = MaterialTheme.typography.bodySmall, 262 ) 263 264 Text( 265 balance.available.toString(showSymbol = false), 266 style = MaterialTheme.typography.titleLarge, 267 fontWeight = FontWeight.Bold, 268 ) 269 } 270 } 271 } 272 273 @Composable 274 fun TransactionRow( 275 tx: Transaction, 276 spec: CurrencySpecification?, 277 isSelected: Boolean, 278 selectionMode: Boolean, 279 onTransactionClick: () -> Unit, 280 onTransactionSelect: () -> Unit, 281 ) { 282 val haptic = LocalHapticFeedback.current 283 284 Column { 285 ListItem( 286 modifier = Modifier 287 .height(IntrinsicSize.Min) 288 .defaultMinSize(minHeight = 80.dp) 289 .combinedClickable( 290 onClick = onTransactionClick, 291 onLongClick = { 292 haptic.performHapticFeedback(HapticFeedbackType.LongPress) 293 onTransactionSelect() 294 }, 295 ), 296 trailingContent = { 297 Box( 298 modifier = Modifier 299 .fillMaxHeight() 300 .padding(8.dp), 301 contentAlignment = Center, 302 ) { 303 TransactionAmountInfo(tx, spec) 304 } 305 }, 306 leadingContent = { 307 Box( 308 modifier = Modifier 309 .fillMaxHeight() 310 .padding(8.dp), 311 contentAlignment = Center, 312 ) { 313 if (!selectionMode) { 314 Icon(painterResource(tx.icon), contentDescription = null) 315 } else if (isSelected) { 316 Icon( 317 Icons.Rounded.CheckCircle, 318 contentDescription = null, 319 tint = MaterialTheme.colorScheme.primary, 320 ) 321 } else { 322 Icon( 323 Icons.Rounded.RadioButtonUnchecked, 324 contentDescription = null, 325 tint = MaterialTheme.colorScheme.outline, 326 ) 327 } 328 } 329 }, 330 headlineContent = { 331 Column(Modifier.padding(vertical = 8.dp)) { 332 Text( 333 text = tx.timestamp.ms.toRelativeTime(LocalContext.current).toString(), 334 style = MaterialTheme.typography.labelSmall, 335 color = MaterialTheme.colorScheme.onSurfaceVariant, 336 ) 337 Text( 338 tx.getTitle(), 339 modifier = Modifier.padding(vertical = 3.dp), 340 style = MaterialTheme.typography.titleMedium, 341 ) 342 ProvideTextStyle( 343 MaterialTheme.typography.bodyMedium.copy( 344 color = MaterialTheme.colorScheme.onSurfaceVariant 345 ) 346 ) { 347 TransactionExtraInfo(tx) 348 } 349 } 350 }, 351 colors = ListItemDefaults.colors( 352 containerColor = if (isSelected) { 353 MaterialTheme.colorScheme.secondaryContainer 354 } else { 355 ListItemDefaults.containerColor 356 } 357 ) 358 ) 359 360 HorizontalDivider() 361 } 362 } 363 364 @Composable 365 fun TransactionAmountInfo( 366 tx: Transaction, 367 spec: CurrencySpecification?, 368 ) { 369 Column(horizontalAlignment = Alignment.End) { 370 ProvideTextStyle(MaterialTheme.typography.titleLarge) { 371 val amountStr = tx.amountEffective.withSpec(spec).toString(showSymbol = false) 372 if (tx.amountEffective.isZero()) { 373 Text(amountStr) 374 } else when (tx.amountType) { 375 Positive -> Text( 376 stringResource(R.string.amount_positive, amountStr), 377 color = if (tx.txState.major == Pending || tx.txState.major == Dialog) 378 Color.Unspecified else TalerTheme.extraColors.success, 379 ) 380 Negative -> Text( 381 stringResource(R.string.amount_negative, amountStr), 382 color = if (tx.txState.major == Pending || tx.txState.major == Dialog) 383 Color.Unspecified else MaterialTheme.colorScheme.error, 384 ) 385 Neutral -> Text(amountStr) 386 } 387 } 388 389 if (tx.txState.major == Pending || tx.txState.major == Dialog) { 390 Badge( 391 Modifier.padding(top = 3.dp), 392 containerColor = MaterialTheme.colorScheme.secondary, 393 ) { 394 Text(stringResource(R.string.transaction_pending)) 395 } 396 } 397 } 398 } 399 400 @Composable 401 fun TransactionExtraInfo(tx: Transaction) { 402 when { 403 tx.txState.major == Aborted -> Text( 404 stringResource(R.string.payment_aborted), 405 color = MaterialTheme.colorScheme.error, 406 ) 407 408 tx.txState.minor == Repurchase -> Text( 409 stringResource(R.string.payment_repurchase), 410 ) 411 412 tx.txState.major == Failed -> Text( 413 stringResource(R.string.payment_failed), 414 color = MaterialTheme.colorScheme.error, 415 ) 416 417 tx.txState.major == Aborting -> Text( 418 stringResource(R.string.payment_aborting), 419 color = MaterialTheme.colorScheme.error, 420 ) 421 422 tx.txState.major == Pending -> when(tx.txState.minor) { 423 BankConfirmTransfer -> Text(stringResource(R.string.withdraw_waiting_confirm)) 424 KycInit -> Text(stringResource(R.string.transaction_preparing_kyc)) 425 KycRequired, 426 KycAuthRequired, 427 BalanceKycRequired -> Text(stringResource(R.string.transactions_required_kyc)) 428 else -> {} 429 } 430 431 tx.txState.major == Dialog -> {} 432 433 tx is TransactionWithdrawal && !tx.confirmed -> Text(stringResource(R.string.withdraw_waiting_confirm)) 434 tx is TransactionPeerPushCredit && tx.info.summary != null -> Text(tx.info.summary) 435 tx is TransactionPeerPushDebit && tx.info.summary != null -> Text(tx.info.summary) 436 tx is TransactionPeerPullCredit && tx.info.summary != null -> Text(tx.info.summary) 437 tx is TransactionPeerPullDebit && tx.info.summary != null -> Text(tx.info.summary) 438 } 439 } 440 441 @Composable 442 private fun getHeaderCurrency( 443 balance: BalanceItem, 444 spec: CurrencySpecification?, 445 ) = if (spec != null) { 446 if (spec.symbol != null && spec.name != spec.symbol) { 447 // Name (symbol) 448 stringResource(R.string.transactions_currency, spec.name, spec.symbol!!) 449 } else if (spec.name != balance.currency) { 450 // Name (currency string) 451 stringResource(R.string.transactions_currency, spec.name, balance.currency) 452 } else balance.currency 453 } else balance.currency 454 455 private val previewBalance = BalanceItem( 456 scopeInfo = Exchange("MXN", "https://exchange.taler.banxico.org.mx"), 457 available = Amount.fromJSONString("MXN:5.50"), 458 pendingIncoming = Amount.fromJSONString("MXN:1.40"), 459 pendingOutgoing = Amount.fromJSONString("MXN:0"), 460 disablePeerPayments = false, 461 ) 462 463 @Preview 464 @Composable 465 fun TransactionsComposableDonePreview() { 466 val t = TransactionWithdrawal( 467 transactionId = "transactionId", 468 timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), 469 txState = TransactionState(Done), 470 txActions = listOf(Retry, Suspend, Abort), 471 exchangeBaseUrl = "https://exchange.demo.taler.net/", 472 withdrawalDetails = WithdrawalDetails.TalerBankIntegrationApi(false), 473 amountRaw = Amount.fromString("TESTKUDOS", "42.23"), 474 amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), 475 error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), 476 scopes = listOf(Exchange( 477 currency = "TESTKUDOS", 478 url = "exchange.test.taler.net", 479 )) 480 ) 481 482 val transactions = listOf(t) 483 484 TalerSurface { 485 TransactionsComposable( 486 innerPadding = PaddingValues(0.dp), 487 balance = previewBalance, 488 viewMode = ViewMode.Transactions(previewBalance.scopeInfo), 489 txResult = Success(transactions), 490 onTransactionClick = {}, 491 onShowBalancesClicked = {}, 492 selectionMode = false, 493 selectedItems = mutableListOf(), 494 onToggleSelection = {}, 495 ) 496 } 497 } 498 499 @Preview 500 @Composable 501 fun TransactionsComposablePendingPreview() { 502 val t = TransactionWithdrawal( 503 transactionId = "transactionId", 504 timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), 505 txState = TransactionState(Pending), 506 txActions = listOf(Retry, Suspend, Abort), 507 exchangeBaseUrl = "https://exchange.demo.taler.net/", 508 withdrawalDetails = WithdrawalDetails.TalerBankIntegrationApi(false), 509 amountRaw = Amount.fromString("TESTKUDOS", "42.23"), 510 amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), 511 error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), 512 scopes = listOf(Exchange( 513 currency = "TESTKUDOS", 514 url = "exchange.test.taler.net", 515 )) 516 ) 517 518 val transactions = listOf(t) 519 520 TalerSurface { 521 TransactionsComposable( 522 innerPadding = PaddingValues(0.dp), 523 balance = previewBalance, 524 viewMode = ViewMode.Transactions(previewBalance.scopeInfo), 525 txResult = Success(transactions), 526 onTransactionClick = {}, 527 onShowBalancesClicked = {}, 528 selectionMode = false, 529 selectedItems = mutableListOf(), 530 onToggleSelection = {}, 531 ) 532 } 533 } 534 535 @Preview 536 @Composable 537 fun TransactionsComposableEmptyPreview() { 538 TalerSurface { 539 TransactionsComposable( 540 innerPadding = PaddingValues(0.dp), 541 balance = previewBalance, 542 viewMode = ViewMode.Transactions(previewBalance.scopeInfo), 543 txResult = Success(listOf()), 544 onTransactionClick = {}, 545 onShowBalancesClicked = {}, 546 selectionMode = false, 547 selectedItems = mutableListOf(), 548 onToggleSelection = {}, 549 ) 550 } 551 } 552 553 @Preview 554 @Composable 555 fun TransactionsComposableLoadingPreview() { 556 TalerSurface { 557 TransactionsComposable( 558 innerPadding = PaddingValues(0.dp), 559 balance = previewBalance, 560 viewMode = ViewMode.Transactions(previewBalance.scopeInfo), 561 txResult = None, 562 onTransactionClick = {}, 563 onShowBalancesClicked = {}, 564 selectionMode = false, 565 selectedItems = mutableListOf(), 566 onToggleSelection = {}, 567 ) 568 } 569 }