PromptPaymentComposable.kt (43474B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2025 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.payment 18 19 import android.graphics.Bitmap 20 import androidx.compose.animation.animateContentSize 21 import androidx.compose.foundation.BorderStroke 22 import androidx.compose.foundation.Image 23 import androidx.compose.foundation.clickable 24 import androidx.compose.foundation.layout.Box 25 import androidx.compose.foundation.layout.Column 26 import androidx.compose.foundation.layout.Row 27 import androidx.compose.foundation.layout.fillMaxSize 28 import androidx.compose.foundation.layout.fillMaxWidth 29 import androidx.compose.foundation.layout.imePadding 30 import androidx.compose.foundation.layout.padding 31 import androidx.compose.foundation.layout.size 32 import androidx.compose.foundation.lazy.LazyColumn 33 import androidx.compose.foundation.relocation.BringIntoViewRequester 34 import androidx.compose.foundation.relocation.bringIntoViewRequester 35 import androidx.compose.material.icons.Icons 36 import androidx.compose.material.icons.filled.Error 37 import androidx.compose.material.icons.filled.EventRepeat 38 import androidx.compose.material.icons.filled.Warning 39 import androidx.compose.material3.AlertDialog 40 import androidx.compose.material3.Button 41 import androidx.compose.material3.Card 42 import androidx.compose.material3.CardDefaults 43 import androidx.compose.material3.ExperimentalMaterial3Api 44 import androidx.compose.material3.Icon 45 import androidx.compose.material3.IconButton 46 import androidx.compose.material3.ListItem 47 import androidx.compose.material3.ListItemDefaults 48 import androidx.compose.material3.MaterialTheme 49 import androidx.compose.material3.OutlinedButton 50 import androidx.compose.material3.OutlinedCard 51 import androidx.compose.material3.PlainTooltip 52 import androidx.compose.material3.Text 53 import androidx.compose.material3.TextButton 54 import androidx.compose.material3.TooltipBox 55 import androidx.compose.material3.TooltipDefaults 56 import androidx.compose.material3.rememberTooltipState 57 import androidx.compose.runtime.Composable 58 import androidx.compose.runtime.LaunchedEffect 59 import androidx.compose.runtime.getValue 60 import androidx.compose.runtime.mutableIntStateOf 61 import androidx.compose.runtime.mutableStateOf 62 import androidx.compose.runtime.remember 63 import androidx.compose.runtime.rememberCoroutineScope 64 import androidx.compose.runtime.saveable.rememberSaveable 65 import androidx.compose.runtime.setValue 66 import androidx.compose.ui.Alignment 67 import androidx.compose.ui.Alignment.Companion.CenterHorizontally 68 import androidx.compose.ui.Modifier 69 import androidx.compose.ui.graphics.asImageBitmap 70 import androidx.compose.ui.res.painterResource 71 import androidx.compose.ui.res.stringResource 72 import androidx.compose.ui.tooling.preview.Preview 73 import androidx.compose.ui.unit.dp 74 import kotlinx.coroutines.launch 75 import net.taler.common.Amount 76 import net.taler.common.ContractChoice 77 import net.taler.common.ContractInput 78 import net.taler.common.ContractOutput 79 import net.taler.common.ContractProduct 80 import net.taler.common.ContractTerms 81 import net.taler.common.ContractTokenDetails 82 import net.taler.common.ContractTokenFamily 83 import net.taler.common.Exchange 84 import net.taler.common.Merchant 85 import net.taler.common.TalerUtils 86 import net.taler.lib.android.base64Bitmap 87 import net.taler.wallet.R 88 import net.taler.wallet.cleanExchange 89 import net.taler.wallet.compose.BottomButtonBox 90 import net.taler.wallet.compose.ExpandableSection 91 import net.taler.wallet.compose.MerchantAvatar 92 import net.taler.wallet.compose.TalerSurface 93 import net.taler.wallet.compose.cardPaddings 94 import net.taler.wallet.donau.DonauInfo 95 import net.taler.wallet.donau.DonauSelector 96 import net.taler.wallet.donau.DonauToggle 97 import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.InsufficientBalance 98 import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.PaymentPossible 99 import net.taler.wallet.payment.TokenAvailabilityHint.MerchantUnexpected 100 import net.taler.wallet.payment.TokenAvailabilityHint.MerchantUntrusted 101 import net.taler.wallet.payment.TokenAvailabilityHint.WalletTokensAvailableInsufficient 102 import net.taler.wallet.systemBarsPaddingBottom 103 104 // TODO: error handling 105 // TODO: info sheets 106 107 @Composable 108 fun PromptPaymentComposable( 109 status: PayStatus.Choices, 110 onConfirm: (choiceIndex: Int?, useDonau: Boolean) -> Unit, 111 onCancel: () -> Unit, 112 onClickImage: (Bitmap) -> Unit, 113 onSetupDonau: (donauBaseUrl: String) -> Unit, 114 checkDonauStatus: suspend (choiceIndex: Int) -> DonauStatus, 115 modifier: Modifier = Modifier, 116 ) { 117 val contractTerms = status.contractTerms 118 var showCancelDialog by rememberSaveable { mutableStateOf(false) } 119 120 OrderCancelDialog( 121 showCancelDialog, 122 onDismiss = { showCancelDialog = false }, 123 onConfirm = { onCancel() }, 124 ) 125 126 Column( 127 modifier 128 .fillMaxSize() 129 .imePadding(), 130 ) { 131 var orderExpanded by rememberSaveable { 132 mutableStateOf(contractTerms is ContractTerms.V0) 133 } 134 var choicesExpanded by rememberSaveable { mutableStateOf(true) } 135 var selectedIndex by rememberSaveable { mutableIntStateOf(status.defaultChoiceIndex ?: 0) } 136 var donauStatus: DonauStatus by remember { mutableStateOf(DonauStatus.Unavailable) } 137 138 LazyColumn( 139 Modifier 140 .weight(1f) 141 .fillMaxWidth(), 142 ) { 143 item { MerchantSection(contractTerms, onClickImage) } 144 145 // REVIEW ORDER SECTION 146 item { 147 ExpandableSection( 148 expanded = orderExpanded, 149 setExpanded = { orderExpanded = it }, 150 header = { Text(stringResource(R.string.payment_section_review)) }, 151 ) { 152 OrderSection(contractTerms, onClickImage) 153 } 154 } 155 156 // PAYMENT OPTIONS SECTION 157 if (contractTerms is ContractTerms.V1) { 158 item { 159 ExpandableSection( 160 expanded = choicesExpanded, 161 setExpanded = { choicesExpanded = it }, 162 header = { Text(stringResource(R.string.payment_section_choices)) }, 163 ) { 164 ChoicesSection( 165 status = status, 166 tokenFamilies = contractTerms.tokenFamilies, 167 selectedIndex = selectedIndex, 168 merchantBaseUrl = contractTerms.merchantBaseUrl, 169 onSelect = { index -> selectedIndex = index }, 170 onConfirm = onConfirm, 171 donauStatus = donauStatus, 172 onSetupDonau = onSetupDonau, 173 ) 174 } 175 } 176 177 item { 178 LaunchedEffect(selectedIndex) { 179 donauStatus = checkDonauStatus(selectedIndex) 180 } 181 } 182 } 183 } 184 185 BottomButtonBox(Modifier.fillMaxWidth(), 186 heading = if (contractTerms is ContractTerms.V0) { -> 187 val choice = status.choices.firstOrNull() 188 ?: error("no v0 choice") 189 190 Text( 191 stringResource( 192 R.string.payment_amount_total, 193 choice.details.amountRaw, 194 ) 195 ) 196 197 if (choice.details is PaymentPossible) { 198 PaymentFeeLabel( 199 modifier = Modifier.padding(bottom = 3.dp), 200 amountRaw = choice.details.amountRaw, 201 amountEffective = choice.details.amountEffective, 202 ) 203 } else if (choice.details is InsufficientBalance) { 204 Text( 205 modifier = Modifier.padding(bottom = 3.dp), 206 text = stringResource(R.string.payment_balance_insufficient), 207 color = MaterialTheme.colorScheme.error, 208 ) 209 } 210 } else null, 211 leading = { 212 OutlinedButton( 213 modifier = Modifier.systemBarsPaddingBottom(), 214 enabled = true, 215 onClick = { showCancelDialog = true }, 216 ) { 217 Text( 218 stringResource(R.string.payment_button_cancel), 219 color = MaterialTheme.colorScheme.error 220 ) 221 } 222 }, 223 trailing = { 224 if (contractTerms is ContractTerms.V0) { 225 val choice = status.choices.firstOrNull() 226 ?: error("no v0 choice") 227 Button( 228 modifier = Modifier.systemBarsPaddingBottom(), 229 enabled = choice.details is PaymentPossible, 230 onClick = { onConfirm(null, false) }, 231 ) { 232 if (choice.details is PaymentPossible) { 233 Text(stringResource( 234 R.string.payment_button_confirm_amount, 235 choice.details.amountEffective, 236 )) 237 } else { 238 Text(stringResource( 239 R.string.payment_button_confirm_amount, 240 choice.details.amountRaw, 241 )) 242 } 243 } 244 } 245 }, 246 ) 247 } 248 } 249 250 @Composable 251 fun MerchantSection( 252 contractTerms: ContractTerms, 253 onClickImage: (Bitmap) -> Unit, 254 ) { 255 val merchant = contractTerms.merchant 256 257 Column( 258 modifier = Modifier 259 .padding(16.dp) 260 .fillMaxWidth(), 261 horizontalAlignment = CenterHorizontally, 262 ) { 263 // MERCHANT LOGO 264 MerchantAvatar(contractTerms.merchant, onClickImage = onClickImage) 265 266 // MERCHANT NAME 267 Text( 268 merchant.name, 269 modifier = Modifier.padding(top = 16.dp), 270 style = MaterialTheme.typography.titleLarge, 271 ) 272 273 // MERCHANT INFO BUTTON 274 // TextButton(onClick = {}) { 275 // Icon( 276 // Icons.Outlined.Info, 277 // contentDescription = null, 278 // modifier = Modifier.size(ButtonDefaults.IconSize), 279 // ) 280 // Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 281 // Text("Merchant info") 282 // } 283 } 284 } 285 286 @Composable 287 fun MerchantInfoSheet() {} 288 289 @Composable 290 fun OrderCancelDialog( 291 show: Boolean, 292 onDismiss: () -> Unit, 293 onConfirm: () -> Unit, 294 ) { 295 if (show) AlertDialog( 296 title = { Text(stringResource(R.string.payment_cancel_dialog_title)) }, 297 text = { Text(stringResource(R.string.payment_cancel_dialog_message)) }, 298 onDismissRequest = { onDismiss() }, 299 confirmButton = { 300 TextButton(onClick = { 301 onDismiss() 302 }) { 303 Text(stringResource(R.string.button_back)) 304 } 305 }, 306 dismissButton = { 307 TextButton(onClick = { 308 onConfirm() 309 }) { 310 Text(stringResource(R.string.payment_cancel_dialog_title)) 311 } 312 }, 313 ) 314 } 315 316 @Composable 317 fun OrderSection( 318 contractTerms: ContractTerms, 319 onClickImage: (Bitmap) -> Unit, 320 ) { 321 Column(horizontalAlignment = CenterHorizontally) { 322 // ORDER SUMMARY 323 Text( 324 contractTerms.summary, 325 modifier = Modifier 326 .padding(top = 3.dp, bottom = 12.dp) 327 .padding(horizontal = 16.dp) 328 .fillMaxWidth(), 329 ) 330 331 // PRODUCT LIST 332 // TODO: LazyColumn would be better, but can't be nested 333 val products = contractTerms.products 334 products?.forEach { product -> 335 ProductItem(product, onClickImage) 336 } 337 338 // ORDER INFO BUTTON 339 // TextButton( 340 // onClick = {}, 341 // modifier = Modifier.padding(bottom = 9.dp), 342 // ) { 343 // Icon( 344 // Icons.Outlined.Info, 345 // contentDescription = null, 346 // modifier = Modifier.size(ButtonDefaults.IconSize), 347 // ) 348 // Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 349 // Text("More details") 350 // } 351 } 352 } 353 354 @Composable 355 fun ProductItem( 356 product: ContractProduct, 357 onClickImage: (Bitmap) -> Unit, 358 ) { 359 val image = remember(product.image) { 360 product.image?.base64Bitmap 361 } 362 363 ListItem( 364 leadingContent = { 365 // IMAGE 366 if (image != null) { 367 Image( 368 image.asImageBitmap(), 369 modifier = Modifier 370 .size(30.dp) 371 .clickable { onClickImage(image) }, 372 contentDescription = null, 373 ) 374 } 375 }, 376 377 headlineContent = { 378 Column( 379 Modifier.fillMaxWidth(), 380 horizontalAlignment = Alignment.End, 381 ) { 382 // NAME 383 Text( 384 product.localizedDescription, 385 modifier = Modifier.fillMaxWidth(), 386 style = MaterialTheme.typography.bodyLarge, 387 ) 388 389 // PRICE 390 product.price?.let { price -> 391 Text( 392 if (product.quantity > 1) { 393 stringResource( 394 R.string.payment_product_price_quantity, 395 product.quantity.toString(), 396 price.toString(), 397 ) 398 } else { 399 price.toString() 400 }, 401 style = MaterialTheme.typography.bodyMedium.copy( 402 color = ListItemDefaults.colors().supportingTextColor, 403 ), 404 ) 405 } 406 } 407 }, 408 ) 409 } 410 411 @Composable 412 fun ChoicesSection( 413 status: PayStatus.Choices, 414 tokenFamilies: Map<String, ContractTokenFamily>, 415 selectedIndex: Int, 416 merchantBaseUrl: String, 417 onSelect: (choiceIndex: Int) -> Unit, 418 onConfirm: (choiceIndex: Int, useDonau: Boolean) -> Unit, 419 donauStatus: DonauStatus, 420 onSetupDonau: (donauBaseUrl: String) -> Unit, 421 ) { 422 // TODO: CURRENCIES 423 424 // CHOICES 425 // TODO: LazyColumn would be better, but can't be nested 426 status.choices.forEach { choice -> 427 PaymentChoice( 428 choice = choice, 429 tokenFamilies = tokenFamilies, 430 merchantBaseUrl = merchantBaseUrl, 431 selected = selectedIndex == choice.choiceIndex, 432 onSelect = { onSelect(choice.choiceIndex) }, 433 donauStatus = donauStatus, 434 onSetupDonau = onSetupDonau, 435 onConfirm = { useDonau -> onConfirm(choice.choiceIndex, useDonau) }, 436 ) 437 } 438 } 439 440 @Composable 441 fun PaymentChoice( 442 choice: PayChoiceDetails, 443 tokenFamilies: Map<String, ContractTokenFamily>, 444 merchantBaseUrl: String, 445 selected: Boolean, 446 onSelect: () -> Unit, 447 onConfirm: (useDonau: Boolean) -> Unit, 448 donauStatus: DonauStatus, 449 onSetupDonau: (donauBaseUrl: String) -> Unit, 450 ) { 451 val bringIntoViewRequester = remember { BringIntoViewRequester() } 452 val coroutineScope = rememberCoroutineScope() 453 454 OutlinedCard( 455 modifier = Modifier 456 .cardPaddings() 457 .fillMaxWidth() 458 .bringIntoViewRequester(bringIntoViewRequester) 459 .animateContentSize { _, _ -> 460 if (selected) { 461 coroutineScope.launch { 462 bringIntoViewRequester.bringIntoView() 463 } 464 } 465 } 466 .clickable { onSelect() }, 467 border = if (selected) { 468 BorderStroke(2.5.dp, MaterialTheme.colorScheme.primary) 469 } else { 470 CardDefaults.outlinedCardBorder() 471 }, 472 ) { 473 Column(Modifier) { 474 Column(Modifier.padding(15.dp)) { 475 // CHOICE AMOUNT 476 Text( 477 text = if (choice.amountRaw.isZero()) { 478 stringResource(R.string.payment_button_confirm_tokens) 479 } else { 480 choice.amountRaw.toString() 481 }, 482 style = MaterialTheme.typography.titleLarge, 483 ) 484 485 if (choice.details is PaymentPossible) { 486 PaymentFeeLabel( 487 amountRaw = choice.details.amountRaw, 488 amountEffective = choice.details.amountEffective, 489 ) 490 } 491 492 choice.localizedDescription?.let { 493 Text( 494 text = it, 495 modifier = Modifier.padding(top = 9.dp), 496 style = MaterialTheme.typography.bodyLarge, 497 ) 498 } 499 500 // INPUTS 501 if (choice.inputs.isNotEmpty()) { 502 Column { 503 Text( 504 stringResource(R.string.payment_choice_inputs), 505 modifier = Modifier.padding(top = 9.dp, bottom = 3.dp), 506 style = MaterialTheme.typography.labelLarge, 507 ) 508 509 choice.inputs.forEach { input -> 510 PaymentInput(input, merchantBaseUrl, tokenFamilies, choice.details.tokenDetails) 511 } 512 } 513 } 514 515 // OUTPUTS 516 if (choice.outputs.isNotEmpty()) { 517 Column { 518 Text( 519 stringResource(R.string.payment_choice_outputs), 520 modifier = Modifier.padding(top = 9.dp, bottom = 3.dp), 521 style = MaterialTheme.typography.labelLarge, 522 ) 523 524 choice.outputs.forEach { output -> 525 PaymentOutput(output, tokenFamilies, merchantBaseUrl) 526 } 527 } 528 } 529 530 // DONAU TOGGLE/SELECTOR 531 var useDonau by rememberSaveable { mutableStateOf(false) } 532 var showSelector by remember { mutableStateOf(false) } 533 if (choice.details is PaymentPossible) { 534 if (selected) DonauToggle( 535 donauStatus = donauStatus, 536 useDonau = useDonau, 537 onToggleDonau = { useDonau = it }, 538 ) 539 540 DonauSelector( 541 donauStatus = donauStatus, 542 showDialog = showSelector, 543 onSetup = { onSetupDonau(it) }, 544 onDismiss = { showSelector = false }, 545 ) 546 } 547 548 val shouldSetDonau = choice.details is PaymentPossible 549 && useDonau 550 && donauStatus !is DonauStatus.Available 551 552 // CONFIRM BUTTON 553 if (selected) Button( 554 modifier = Modifier 555 .padding(top = 9.dp) 556 .fillMaxWidth(), 557 onClick = { 558 if (shouldSetDonau) { 559 showSelector = true 560 } else { 561 onConfirm(useDonau) 562 } 563 }, 564 enabled = choice.details is PaymentPossible, 565 ) { 566 val tokenDetails = choice.details.tokenDetails 567 Text( 568 if (choice.details is PaymentPossible) { 569 if (shouldSetDonau) { 570 stringResource(R.string.donau_select_button) 571 } else if (choice.details.amountEffective.isZero()) { 572 stringResource(R.string.payment_button_confirm_tokens) 573 } else { 574 stringResource( 575 R.string.payment_button_confirm_amount, 576 choice.details.amountEffective 577 ) 578 } 579 } else if (tokenDetails != null && 580 tokenDetails.tokensAvailable 581 < tokenDetails.tokensRequested) { 582 stringResource( 583 R.string.payment_tokens_insufficient, 584 ) 585 } else { 586 stringResource( 587 R.string.payment_balance_insufficient, 588 ) 589 }, 590 ) 591 } 592 } 593 } 594 } 595 } 596 597 @Composable 598 fun PaymentInput( 599 input: ContractInput, 600 merchantBaseUrl: String, 601 tokenFamilies: Map<String, ContractTokenFamily>, 602 tokenAvailabilityDetails: PaymentTokenAvailabilityDetails?, 603 ) { 604 when (input) { 605 is ContractInput.Token -> { 606 // TODO: calculate from outside? 607 // TODO: better error handling 608 val family = tokenFamilies[input.tokenFamilySlug] 609 ?: error("no token family ${input.tokenFamilySlug}") 610 611 val availability = tokenAvailabilityDetails?.perTokenFamily 612 ?.get(input.tokenFamilySlug) 613 614 TokenCard( 615 name = family.name, 616 description = TalerUtils.getLocalizedString( 617 family.descriptionI18n, 618 family.description, 619 ), 620 count = input.count, 621 details = family.details, 622 merchantBaseUrl = merchantBaseUrl, 623 availabilityHint = availability?.causeHint, 624 ) 625 } 626 } 627 } 628 629 @Composable 630 fun PaymentOutput( 631 output: ContractOutput, 632 tokenFamilies: Map<String, ContractTokenFamily>, 633 merchantBaseUrl: String, 634 ) { 635 when (output) { 636 is ContractOutput.Token -> { 637 // TODO: calculate from outside? 638 // TODO: better error handling 639 val family = tokenFamilies[output.tokenFamilySlug] 640 ?: error("no token family for ${output.tokenFamilySlug}") 641 642 TokenCard( 643 name = family.name, 644 description = TalerUtils.getLocalizedString( 645 family.descriptionI18n, 646 family.description, 647 ), 648 count = output.count, 649 details = family.details, 650 merchantBaseUrl = merchantBaseUrl, 651 ) 652 } 653 654 // TODO: ContractOutput.TaxReceipt 655 656 else -> {} 657 } 658 } 659 660 @Composable 661 fun TokenCard( 662 name: String, 663 description: String, 664 count: Int, 665 details: ContractTokenDetails, 666 merchantBaseUrl: String, 667 availabilityHint: TokenAvailabilityHint? = null, 668 ) { 669 Card ( 670 modifier = Modifier 671 .padding(vertical = 5.dp) 672 .fillMaxWidth(), 673 ) { 674 Row( 675 modifier = Modifier.padding(vertical = 8.dp), 676 verticalAlignment = Alignment.CenterVertically 677 ) { 678 Box( 679 Modifier.padding(horizontal = 12.dp), 680 contentAlignment = Alignment.Center, 681 ) { 682 when (details) { 683 is ContractTokenDetails.Discount -> Icon( 684 painterResource(R.drawable.ic_discounts), 685 contentDescription = stringResource(R.string.payment_token_discount), 686 tint = MaterialTheme.colorScheme.onSurfaceVariant, 687 ) 688 689 is ContractTokenDetails.Subscription -> Icon( 690 Icons.Default.EventRepeat, 691 contentDescription = stringResource(R.string.payment_token_subscription), 692 tint = MaterialTheme.colorScheme.onSurfaceVariant, 693 ) 694 } 695 } 696 697 Column(Modifier.weight(1f)) { 698 Text( 699 if (count > 1) { 700 stringResource(R.string.payment_product_price_quantity, count, name) 701 } else { 702 name 703 }, 704 modifier = Modifier.padding(bottom = 4.dp), 705 style = MaterialTheme.typography.bodyMedium.copy( 706 color = MaterialTheme.colorScheme.onSurface, 707 ), 708 ) 709 710 Text( 711 description, 712 style = MaterialTheme.typography.bodySmall.copy( 713 color = MaterialTheme.colorScheme.onSurfaceVariant 714 ), 715 ) 716 } 717 718 Row(verticalAlignment = Alignment.CenterVertically) { 719 if (availabilityHint != null) { 720 TokenWarningTooltip(merchantBaseUrl, availabilityHint) 721 } 722 } 723 } 724 } 725 } 726 727 @OptIn(ExperimentalMaterial3Api::class) 728 @Composable 729 fun TokenWarningTooltip( 730 merchantBaseUrl: String, 731 availabilityHint: TokenAvailabilityHint, 732 ) { 733 val icon = when(availabilityHint) { 734 WalletTokensAvailableInsufficient -> Icons.Default.Error 735 MerchantUntrusted -> Icons.Default.Error 736 MerchantUnexpected -> Icons.Default.Warning 737 else -> return 738 } 739 740 val tint = when(availabilityHint) { 741 WalletTokensAvailableInsufficient -> MaterialTheme.colorScheme.error 742 MerchantUntrusted -> MaterialTheme.colorScheme.error 743 MerchantUnexpected -> MaterialTheme.colorScheme.onSurfaceVariant 744 } 745 746 val text = when(availabilityHint) { 747 WalletTokensAvailableInsufficient -> stringResource(R.string.payment_tokens_insufficient) 748 MerchantUntrusted -> stringResource(R.string.payment_tokens_untrusted, cleanExchange(merchantBaseUrl)) 749 MerchantUnexpected -> stringResource(R.string.payment_tokens_unexpected, cleanExchange(merchantBaseUrl)) 750 } 751 752 val tooltipState = rememberTooltipState() 753 val coroutineScope = rememberCoroutineScope() 754 755 TooltipBox( 756 modifier = Modifier, 757 positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), 758 tooltip = { PlainTooltip { Text(text) } }, 759 state = tooltipState, 760 ) { 761 IconButton(onClick = { 762 coroutineScope.launch { 763 tooltipState.show() 764 } 765 }) { 766 Icon( 767 icon, 768 tint = tint, 769 contentDescription = when(availabilityHint) { 770 WalletTokensAvailableInsufficient -> stringResource(R.string.error) 771 MerchantUntrusted -> stringResource(R.string.error) 772 MerchantUnexpected -> stringResource(R.string.warning) 773 }, 774 ) 775 } 776 } 777 } 778 779 @Composable 780 fun PaymentFeeLabel( 781 modifier: Modifier = Modifier, 782 amountRaw: Amount, 783 amountEffective: Amount, 784 ) { 785 if (amountEffective > amountRaw) { 786 val fee = amountEffective - amountRaw 787 Text( 788 modifier = modifier, 789 text = stringResource( 790 R.string.payment_fee, 791 fee.toString(showSymbol = false), 792 ), 793 style = MaterialTheme.typography.labelLarge.copy( 794 color = MaterialTheme.colorScheme.error, 795 ), 796 ) 797 } 798 } 799 800 private val contractTermsV0 = ContractTerms.V0( 801 merchant = Merchant( 802 name = "Demo Shop", 803 logo = "data:image/png;base64,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", 804 ), 805 summary = "order summary", 806 orderId = "102910291029", 807 merchantBaseUrl = "https://backend.demo.taler.net/", 808 amount = Amount.fromJSONString("KUDOS:1"), 809 maxFee = Amount.fromJSONString("KUDOS:0"), 810 products = listOf( 811 ContractProduct( 812 description = "something", 813 price = Amount.fromJSONString("KUDOS:1"), 814 image = "data:image/png;base64,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", 815 quantity = 2, 816 ), 817 818 ContractProduct( 819 description = "something", 820 price = Amount.fromJSONString("KUDOS:1"), 821 image = "data:image/png;base64,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", 822 quantity = 1, 823 ), 824 ), 825 exchanges = listOf( 826 Exchange(url = "https://exchange.demo.taler.net/"), 827 Exchange(url = "https://exchange.test.taler.net/"), 828 ), 829 ) 830 831 private val contractTermsV1 = ContractTerms.V1( 832 merchant = contractTermsV0.merchant, 833 summary = contractTermsV0.summary, 834 orderId = contractTermsV0.orderId, 835 merchantBaseUrl = contractTermsV0.merchantBaseUrl, 836 products = contractTermsV0.products, 837 exchanges = contractTermsV0.exchanges, 838 839 choices = listOf( 840 ContractChoice( 841 amount = Amount.fromJSONString("KUDOS:10"), 842 description = "Tax-deductible movie pass discount", 843 maxFee = Amount.fromJSONString("KUDOS:0"), 844 inputs = listOf( 845 ContractInput.Token(tokenFamilySlug = "half-tax", count = 2), 846 ContractInput.Token(tokenFamilySlug = "movie-pass"), 847 ), 848 outputs = listOf( 849 ContractOutput.Token(tokenFamilySlug = "movie-pass"), 850 ContractOutput.TaxReceipt( 851 amount = Amount.fromJSONString("KUDOS:10"), 852 donauUrls = listOf("https://donau.test.taler.net/"), 853 ) 854 ), 855 ), 856 857 ContractChoice( 858 amount = Amount.fromJSONString("KUDOS:200"), 859 description = "Movie pass access renewal", 860 maxFee = Amount.fromJSONString("KUDOS:0"), 861 inputs = listOf( 862 ContractInput.Token(tokenFamilySlug = "movie-pass"), 863 ), 864 outputs = listOf( 865 ContractOutput.Token(tokenFamilySlug = "movie-pass"), 866 ), 867 ), 868 869 ContractChoice( 870 amount = Amount.fromJSONString("KUDOS:0"), 871 description = "Movie pass access renewal", 872 maxFee = Amount.fromJSONString("KUDOS:0"), 873 inputs = listOf( 874 ContractInput.Token(tokenFamilySlug = "movie-pass"), 875 ), 876 outputs = listOf( 877 ContractOutput.Token(tokenFamilySlug = "movie-pass"), 878 ), 879 ), 880 ), 881 882 tokenFamilies = mapOf( 883 "half-tax" to ContractTokenFamily( 884 name = "half-tax", 885 description = "50% discount", 886 details = ContractTokenDetails.Discount, 887 critical = true, 888 ), 889 890 "movie-pass" to ContractTokenFamily( 891 name = "movie-pass", 892 description = "Monthly movie pass", 893 details = ContractTokenDetails.Subscription, 894 critical = true, 895 ), 896 ), 897 ) 898 899 @Preview 900 @Composable 901 fun PromptPaymentV0Preview() { 902 TalerSurface { 903 PromptPaymentComposable(PayStatus.Choices( 904 transactionId = "txn:payment:2309203920", 905 contractTerms = contractTermsV0, 906 choices = listOf( 907 PayChoiceDetails( 908 choiceIndex = 0, 909 amountRaw = Amount.fromJSONString("KUDOS:10"), 910 inputs = listOf(), 911 outputs = listOf(), 912 details = PaymentPossible( 913 amountRaw = Amount.fromJSONString("KUDOS:10"), 914 amountEffective = Amount.fromJSONString("KUDOS:10.2"), 915 ), 916 ) 917 ) 918 ), { _, _ -> }, {}, {}, {}, { DonauStatus.Unavailable }) 919 } 920 } 921 922 @Preview 923 @Composable 924 fun PromptPaymentV1Preview() { 925 TalerSurface { 926 PromptPaymentComposable(PayStatus.Choices( 927 transactionId = "txn:payment:2309203920", 928 contractTerms = contractTermsV1, 929 defaultChoiceIndex = 2, 930 choices = listOf( 931 PayChoiceDetails( 932 choiceIndex = 0, 933 amountRaw = contractTermsV1.choices[0].amount, 934 description = "Tax-deductible movie pass discount", 935 inputs = contractTermsV1.choices[0].inputs, 936 outputs = contractTermsV1.choices[0].outputs, 937 details = PaymentPossible( 938 amountRaw = contractTermsV1.choices[0].amount, 939 amountEffective = contractTermsV1.choices[0].amount 940 .plus(Amount.fromJSONString("KUDOS:0.1")), 941 tokenDetails = PaymentTokenAvailabilityDetails( 942 tokensRequested = 3, 943 tokensAvailable = 2, 944 tokensUntrusted = 1, 945 tokensUnexpected = 1, 946 perTokenFamily = mapOf( 947 "half-tax" to PaymentTokenAvailabilityDetails.PerTokenFamily( 948 causeHint = MerchantUntrusted, 949 requested = 2, 950 available = 1, 951 untrusted = 1, 952 unexpected = 0 953 ), 954 "movie-pass" to PaymentTokenAvailabilityDetails.PerTokenFamily( 955 causeHint = MerchantUnexpected, 956 requested = 1, 957 available = 1, 958 untrusted = 0, 959 unexpected = 1, 960 ), 961 ), 962 ), 963 ), 964 ), 965 PayChoiceDetails( 966 choiceIndex = 1, 967 amountRaw = contractTermsV1.choices[1].amount, 968 description = "Movie pass access renewal", 969 inputs = contractTermsV1.choices[1].inputs, 970 outputs = contractTermsV1.choices[1].outputs, 971 details = InsufficientBalance( 972 amountRaw = contractTermsV1.choices[1].amount, 973 ), 974 ), 975 PayChoiceDetails( 976 choiceIndex = 2, 977 amountRaw = contractTermsV1.choices[2].amount, 978 description = "Movie pass access renewal", 979 inputs = contractTermsV1.choices[2].inputs, 980 outputs = contractTermsV1.choices[2].outputs, 981 details = PaymentPossible( 982 amountRaw = contractTermsV1.choices[2].amount, 983 amountEffective = contractTermsV1.choices[2].amount, 984 ), 985 ), 986 ) 987 ), { _, _ -> }, {}, {}, {}, { 988 DonauStatus.Mismatch( 989 donauInfo = DonauInfo("https://donau.test.taler.net/", "123"), 990 donauUrls = listOf( 991 "https://donau.demo.taler.net/", 992 "https://donau.head.taler.net/", 993 ), 994 ) 995 }) 996 } 997 }