TransactionDetailScreen.kt (23081B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2026 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 android.content.Context 20 import android.util.Log 21 import androidx.compose.foundation.basicMarquee 22 import androidx.compose.foundation.layout.Box 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.padding 29 import androidx.compose.foundation.layout.size 30 import androidx.compose.foundation.rememberScrollState 31 import androidx.compose.foundation.verticalScroll 32 import androidx.compose.material.icons.Icons 33 import androidx.compose.material.icons.filled.ContentCopy 34 import androidx.compose.material3.ButtonDefaults 35 import androidx.compose.material3.Icon 36 import androidx.compose.material3.MaterialTheme 37 import androidx.compose.material3.Surface 38 import androidx.compose.material3.Text 39 import androidx.compose.material3.TextButton 40 import androidx.compose.runtime.Composable 41 import androidx.compose.runtime.DisposableEffect 42 import androidx.compose.runtime.LaunchedEffect 43 import androidx.compose.runtime.getValue 44 import androidx.compose.runtime.livedata.observeAsState 45 import androidx.compose.runtime.mutableStateOf 46 import androidx.compose.runtime.remember 47 import androidx.compose.runtime.setValue 48 import androidx.compose.ui.Alignment 49 import androidx.compose.ui.Modifier 50 import androidx.compose.ui.graphics.Color 51 import androidx.compose.ui.platform.LocalContext 52 import androidx.compose.ui.res.stringResource 53 import androidx.compose.ui.text.style.TextAlign 54 import androidx.compose.ui.tooling.preview.Preview 55 import androidx.compose.ui.unit.dp 56 import androidx.compose.ui.unit.sp 57 import net.taler.common.Amount 58 import net.taler.common.CurrencySpecification 59 import net.taler.common.Timestamp 60 import net.taler.lib.android.copyToClipBoard 61 import net.taler.lib.android.toAbsoluteTime 62 import net.taler.wallet.BottomInsetsSpacer 63 import net.taler.wallet.NavigateCallback 64 import net.taler.wallet.R 65 import net.taler.wallet.WalletDestination 66 import net.taler.wallet.backend.TalerErrorCode 67 import net.taler.wallet.backend.TalerErrorInfo 68 import net.taler.wallet.balances.ScopeInfo 69 import net.taler.wallet.compose.GlobalScaffold 70 import net.taler.wallet.compose.collectAsStateLifecycleAware 71 import net.taler.wallet.deposit.TransactionDepositComposable 72 import net.taler.wallet.launchInAppBrowser 73 import net.taler.wallet.main.MainViewModel 74 import net.taler.wallet.main.TAG 75 import net.taler.wallet.payment.PayStatus 76 import net.taler.wallet.payment.TransactionPaymentComposable 77 import net.taler.wallet.peer.TransactionPeerPullCreditComposable 78 import net.taler.wallet.peer.TransactionPeerPullDebitComposable 79 import net.taler.wallet.peer.TransactionPeerPushCreditComposable 80 import net.taler.wallet.peer.TransactionPeerPushDebitComposable 81 import net.taler.wallet.refund.TransactionRefundComposable 82 import net.taler.wallet.ui.theme.TalerTheme 83 import net.taler.wallet.withdraw.TransactionWithdrawalComposable 84 85 @Composable 86 fun TransactionDetailScreen( 87 model: MainViewModel, 88 destination: WalletDestination, 89 onNavigate: NavigateCallback, 90 onNavigateBack: () -> Unit, 91 ) { 92 val transactionManager = model.transactionManager 93 val exchangeManager = model.exchangeManager 94 val withdrawManager = model.withdrawManager 95 val devMode by model.devMode.observeAsState(false) 96 val context = LocalContext.current 97 var keepSelectedTx: Boolean by remember { mutableStateOf(false) } 98 99 DisposableEffect(keepSelectedTx) { 100 onDispose { 101 if (!keepSelectedTx) { 102 transactionManager.selectTransaction(null) 103 } 104 } 105 } 106 107 GlobalScaffold( 108 model = model, 109 title = { 110 val title = when (destination) { 111 is WalletDestination.TransactionPayment -> stringResource(R.string.transaction_order) 112 is WalletDestination.TransactionWithdrawal -> stringResource(R.string.withdraw_title) 113 is WalletDestination.TransactionDeposit -> stringResource(R.string.transaction_deposit) 114 is WalletDestination.TransactionRefund -> stringResource(R.string.transaction_refund) 115 is WalletDestination.TransactionRefresh -> stringResource(R.string.transaction_refresh) 116 is WalletDestination.TransactionPeer -> stringResource(R.string.transaction_peer_push_debit) // Approximation 117 is WalletDestination.TransactionLoss -> stringResource(R.string.transaction_denom_loss) 118 else -> stringResource(R.string.transactions_detail_title) 119 } 120 Text(title) 121 }, 122 onNavigateBack = onNavigateBack, 123 ) { paddingValues -> 124 val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() 125 val modifier = Modifier 126 .fillMaxSize() 127 .padding(paddingValues) 128 Box(Modifier.fillMaxSize()) { 129 when (destination) { 130 is WalletDestination.TransactionPayment -> { 131 (t as? TransactionPayment)?.let { tx -> 132 LaunchedEffect(tx.txState) { 133 if (tx.txState.major == TransactionMajorState.Dialog) { 134 model.paymentManager.getPaymentChoices(tx.transactionId) {} 135 } 136 } 137 138 TransactionPaymentComposable( 139 t = tx, 140 payStatus = model.paymentManager.payStatus 141 .observeAsState(PayStatus.None).value, 142 devMode = devMode, 143 spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), 144 modifier = modifier, 145 onFulfill = { url -> 146 launchInAppBrowser(context, url) 147 }, 148 onTransition = { action -> 149 handleTransactionAction(tx, action, model, onNavigateBack) 150 }, 151 onConfirmPay = { choiceIndex, useDonau -> 152 model.paymentManager.confirmPay( 153 transactionId = tx.transactionId, 154 choiceIndex = choiceIndex, 155 useDonau = useDonau, 156 ) 157 }, 158 onSetupDonau = { donauBaseUrl -> 159 onNavigate(WalletDestination.SetDonau(donauBaseUrl), false) 160 }, 161 checkDonauForChoice = { choice -> 162 model.paymentManager.checkDonauForChoice(choice) 163 } 164 ) 165 } 166 } 167 168 is WalletDestination.TransactionWithdrawal -> { 169 (t as? TransactionWithdrawal)?.let { tx -> 170 val qrCode = remember(tx) { 171 (tx.withdrawalDetails as? WithdrawalDetails.ManualTransfer)?.let { details -> 172 if (details.exchangeCreditAccountDetails?.size == 1) { 173 val account0 = details.exchangeCreditAccountDetails[0] 174 val qrCodes = withdrawManager.getQrCodesForPayto(account0.paytoUri) 175 if (qrCodes.size == 1) qrCodes[0] 176 else null 177 } else null 178 } 179 } 180 181 TransactionWithdrawalComposable( 182 modifier = modifier, 183 t = tx, 184 devMode = devMode, 185 spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), 186 qrCode = qrCode, 187 onConfirmKyc = { url -> 188 launchInAppBrowser(context, url) 189 }, 190 onConfirmBank = { 191 if (tx.withdrawalDetails is WithdrawalDetails.TalerBankIntegrationApi) { 192 tx.withdrawalDetails.bankConfirmationUrl?.let { url -> 193 launchInAppBrowser(context, url) 194 } 195 } 196 }, 197 onConfirmManual = { 198 keepSelectedTx = true 199 onNavigate(WalletDestination.WireTransferDetails(false), false) 200 }, 201 onShowQrCodes = { 202 keepSelectedTx = true 203 onNavigate(WalletDestination.WireTransferDetails(true), false) 204 }, 205 onTransition = { action -> 206 handleTransactionAction(tx, action, model, onNavigateBack) 207 } 208 ) 209 } 210 } 211 212 is WalletDestination.TransactionDeposit -> { 213 (t as? TransactionDeposit)?.let { tx -> 214 TransactionDepositComposable( 215 modifier = modifier, 216 t = tx, 217 devMode = devMode, 218 spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), 219 onWireTransfer = { 220 keepSelectedTx = true 221 onNavigate( 222 WalletDestination.WireTransferDetails(false), 223 false 224 ) 225 }, 226 onShowQrCodes = { 227 keepSelectedTx = true 228 onNavigate( 229 WalletDestination.WireTransferDetails(true), 230 false 231 ) 232 }, 233 onTransition = { 234 handleTransactionAction(tx, it, model, onNavigateBack) 235 }, 236 ) 237 } 238 } 239 240 is WalletDestination.TransactionRefund -> { 241 (t as? TransactionRefund)?.let { tx -> 242 TransactionRefundComposable( 243 modifier = modifier, 244 t = tx, 245 devMode = devMode, 246 spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), 247 onTransition = { 248 handleTransactionAction(tx, it, model, onNavigateBack) 249 }, 250 ) 251 } 252 } 253 254 is WalletDestination.TransactionRefresh -> { 255 (t as? TransactionRefresh)?.let { tx -> 256 TransactionRefreshComposable( 257 modifier = modifier, 258 t = tx, 259 devMode = devMode, 260 spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), 261 ) { 262 handleTransactionAction(tx, it, model, onNavigateBack) 263 } 264 } 265 } 266 267 is WalletDestination.TransactionPeer -> { 268 val tx = t 269 if (tx != null) { 270 TransactionPeerComposable( 271 modifier = modifier, 272 t = tx, 273 devMode = devMode, 274 spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), 275 onConfirmKyc = { url -> launchInAppBrowser(context, url) } 276 ) { 277 handleTransactionAction(tx, it, model, onNavigateBack) 278 } 279 } 280 } 281 282 is WalletDestination.TransactionLoss -> { 283 (t as? TransactionDenomLoss)?.let { tx -> 284 TransitionLossComposable( 285 modifier = modifier, 286 t = tx, 287 devMode = devMode, 288 spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes) 289 ) { 290 handleTransactionAction(tx, it, model, onNavigateBack) 291 } 292 } 293 } 294 295 is WalletDestination.TransactionDummy -> { 296 (t as? DummyTransaction)?.let { tx -> 297 TransactionDummyComposable( 298 modifier = modifier, 299 t = tx, 300 ) 301 } 302 } 303 304 else -> {} 305 } 306 } 307 } 308 } 309 310 @Composable 311 fun TransactionRefreshComposable( 312 t: TransactionRefresh, 313 devMode: Boolean, 314 spec: CurrencySpecification?, 315 modifier: Modifier = Modifier, 316 onTransition: (t: TransactionAction) -> Unit, 317 ) { 318 val scrollState = rememberScrollState() 319 Column( 320 modifier = modifier 321 .fillMaxWidth() 322 .verticalScroll(scrollState), 323 horizontalAlignment = Alignment.CenterHorizontally, 324 ) { 325 TransactionStateComposable(state = t.txState) 326 327 Text( 328 modifier = Modifier.padding(16.dp), 329 text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), 330 style = MaterialTheme.typography.bodyLarge, 331 ) 332 333 TransactionAmountComposable( 334 label = stringResource(id = R.string.amount_fee), 335 amount = t.amountEffective.withSpec(spec), 336 amountType = AmountType.Negative, 337 ) 338 339 TransitionsComposable(t, devMode, onTransition) 340 t.error?.let { error -> 341 if (devMode) { 342 ErrorTransactionButton(error = error) 343 } 344 } 345 346 BottomInsetsSpacer() 347 } 348 } 349 350 @Composable 351 fun TransactionPeerComposable( 352 t: Transaction, 353 devMode: Boolean, 354 spec: CurrencySpecification?, 355 modifier: Modifier = Modifier, 356 onConfirmKyc: (url: String) -> Unit, 357 onTransition: (t: TransactionAction) -> Unit, 358 ) { 359 val scrollState = rememberScrollState() 360 Column( 361 modifier = modifier 362 .fillMaxWidth() 363 .verticalScroll(scrollState), 364 horizontalAlignment = Alignment.CenterHorizontally, 365 ) { 366 TransactionStateComposable(state = t.txState) 367 368 Text( 369 modifier = Modifier.padding(16.dp), 370 text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), 371 style = MaterialTheme.typography.bodyLarge, 372 ) 373 374 when (t) { 375 is TransactionPeerPullCredit -> TransactionPeerPullCreditComposable(t, spec, onConfirmKyc) 376 is TransactionPeerPushCredit -> TransactionPeerPushCreditComposable(t, spec, onConfirmKyc) 377 is TransactionPeerPullDebit -> TransactionPeerPullDebitComposable(t, spec) 378 is TransactionPeerPushDebit -> TransactionPeerPushDebitComposable(t, spec) 379 else -> {} 380 } 381 382 TransitionsComposable(t, devMode, onTransition) 383 384 t.error?.let { error -> 385 if (devMode) { 386 ErrorTransactionButton(error = error) 387 } 388 } 389 390 BottomInsetsSpacer() 391 } 392 } 393 394 @Composable 395 fun TransitionLossComposable( 396 t: TransactionDenomLoss, 397 devMode: Boolean, 398 spec: CurrencySpecification?, 399 modifier: Modifier = Modifier, 400 onTransition: (t: TransactionAction) -> Unit, 401 ) { 402 val scrollState = rememberScrollState() 403 404 Column( 405 modifier = modifier 406 .fillMaxWidth() 407 .verticalScroll(scrollState), 408 horizontalAlignment = Alignment.CenterHorizontally, 409 ) { 410 TransactionStateComposable(state = t.txState) 411 412 Text( 413 modifier = Modifier.padding(16.dp), 414 text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(), 415 style = MaterialTheme.typography.bodyLarge, 416 ) 417 418 TransactionAmountComposable( 419 label = stringResource(id = R.string.amount_lost), 420 amount = t.amountEffective.withSpec(spec), 421 amountType = AmountType.Negative, 422 ) 423 424 TransactionInfoComposable( 425 label = stringResource(id = R.string.loss_reason), 426 info = stringResource( 427 when(t.lossEventType) { 428 LossEventType.DenomExpired -> R.string.loss_reason_expired 429 LossEventType.DenomVanished -> R.string.loss_reason_vanished 430 LossEventType.DenomUnoffered -> R.string.loss_reason_unoffered 431 } 432 ) 433 ) 434 435 TransitionsComposable(t, devMode, onTransition) 436 437 t.error?.let { error -> 438 if (devMode) { 439 ErrorTransactionButton(error = error) 440 } 441 } 442 443 BottomInsetsSpacer() 444 } 445 } 446 447 @Composable 448 fun TransactionDummyComposable( 449 t: DummyTransaction, 450 modifier: Modifier = Modifier, 451 ) { 452 val scrollState = rememberScrollState() 453 Column( 454 modifier = modifier 455 .fillMaxWidth() 456 .padding(16.dp) 457 .verticalScroll(scrollState), 458 horizontalAlignment = Alignment.CenterHorizontally, 459 ) { 460 ErrorTransactionButton(error = t.error) 461 BottomInsetsSpacer() 462 } 463 } 464 465 @Composable 466 fun TransactionAmountComposable( 467 label: String, 468 amount: Amount, 469 amountType: AmountType, 470 context: Context? = null, 471 copy: Boolean = false, 472 ) { 473 Text( 474 modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), 475 text = label, 476 style = MaterialTheme.typography.bodyMedium, 477 ) 478 Row( 479 verticalAlignment = Alignment.CenterVertically, 480 ) { 481 Text( 482 modifier = Modifier 483 .padding( 484 top = 8.dp, 485 start = 16.dp, 486 end = 16.dp, 487 bottom = 16.dp, 488 ).weight(1f), 489 text = amount.toString(negative = amountType == AmountType.Negative), 490 textAlign = TextAlign.Center, 491 fontSize = 24.sp, 492 color = when (amountType) { 493 AmountType.Positive -> TalerTheme.extraColors.success 494 AmountType.Negative -> MaterialTheme.colorScheme.error 495 AmountType.Neutral -> Color.Unspecified 496 }, 497 ) 498 499 if (copy) { 500 TextButton( 501 onClick = { context?.let { 502 copyToClipBoard(context, label, amount.toString(showSymbol = false)) 503 } }, 504 ) { 505 Icon( 506 Icons.Default.ContentCopy, 507 contentDescription = null, 508 modifier = Modifier.size(ButtonDefaults.IconSize), 509 ) 510 Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 511 Text(stringResource(R.string.copy)) 512 } 513 } 514 } 515 } 516 517 @Composable 518 fun TransactionInfoComposable( 519 label: String, 520 info: String, 521 marquee: Boolean = false, 522 trailing: (@Composable () -> Unit)? = null, 523 ) { 524 Text( 525 modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), 526 text = label, 527 style = MaterialTheme.typography.bodyMedium, 528 ) 529 530 Row( 531 modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), 532 verticalAlignment = Alignment.CenterVertically, 533 ) { 534 Text( 535 modifier = if (marquee) Modifier.basicMarquee() else Modifier, 536 text = info, 537 fontSize = 24.sp, 538 ) 539 540 trailing?.let { it() } 541 } 542 } 543 544 private fun handleTransactionAction( 545 tx: Transaction, 546 action: TransactionAction, 547 model: MainViewModel, 548 onNavigateBack: () -> Unit, 549 ) { 550 val transactionManager = model.transactionManager 551 val onError: (TalerErrorInfo) -> Unit = { error -> 552 Log.e(TAG, "Error handling transaction action $action: $error") 553 } 554 555 when (action) { 556 TransactionAction.Delete -> transactionManager.deleteTransaction(tx.transactionId, onError).invokeOnCompletion { onNavigateBack() } 557 TransactionAction.Retry -> transactionManager.retryTransaction(tx.transactionId, onError) 558 TransactionAction.Abort -> transactionManager.abortTransaction(tx.transactionId, { onNavigateBack() }, onError) 559 TransactionAction.Fail -> transactionManager.failTransaction(tx.transactionId, onError) 560 TransactionAction.Suspend -> transactionManager.suspendTransaction(tx.transactionId, onError) 561 TransactionAction.Resume -> transactionManager.resumeTransaction(tx.transactionId, onError) 562 } 563 } 564 565 @Preview 566 @Composable 567 fun TransactionRefreshComposablePreview() { 568 val t = TransactionRefresh( 569 transactionId = "transactionId", 570 timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), 571 txState = TransactionState(TransactionMajorState.Pending), 572 txActions = listOf(TransactionAction.Retry, TransactionAction.Suspend, TransactionAction.Abort), 573 amountRaw = Amount.fromString("TESTKUDOS", "42.23"), 574 amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), 575 error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), 576 scopes = listOf(ScopeInfo.Exchange( 577 currency = "TESTKUDOS", 578 url = "exchange.test.taler.net", 579 )) 580 ) 581 Surface { 582 TransactionRefreshComposable(t, true, null) {} 583 } 584 }