ScreenTransfer.kt (17496B)
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.transfer 18 19 import androidx.compose.foundation.layout.Column 20 import androidx.compose.foundation.layout.Row 21 import androidx.compose.foundation.layout.Spacer 22 import androidx.compose.foundation.layout.fillMaxWidth 23 import androidx.compose.foundation.layout.height 24 import androidx.compose.foundation.layout.padding 25 import androidx.compose.foundation.layout.size 26 import androidx.compose.foundation.rememberScrollState 27 import androidx.compose.foundation.verticalScroll 28 import androidx.compose.material.icons.Icons 29 import androidx.compose.material.icons.filled.ContentCopy 30 import androidx.compose.material3.Button 31 import androidx.compose.material3.ButtonDefaults 32 import androidx.compose.material3.Icon 33 import androidx.compose.material3.MaterialTheme 34 import androidx.compose.material3.ScrollableTabRow 35 import androidx.compose.material3.Surface 36 import androidx.compose.material3.Tab 37 import androidx.compose.material3.Text 38 import androidx.compose.material3.TextButton 39 import androidx.compose.runtime.Composable 40 import androidx.compose.runtime.LaunchedEffect 41 import androidx.compose.runtime.getValue 42 import androidx.compose.runtime.mutableStateMapOf 43 import androidx.compose.runtime.mutableStateOf 44 import androidx.compose.runtime.remember 45 import androidx.compose.runtime.setValue 46 import androidx.compose.ui.Alignment 47 import androidx.compose.ui.Modifier 48 import androidx.compose.ui.platform.LocalContext 49 import androidx.compose.ui.res.stringResource 50 import androidx.compose.ui.text.AnnotatedString 51 import androidx.compose.ui.text.font.FontFamily 52 import androidx.compose.ui.text.fromHtml 53 import androidx.compose.ui.text.style.LineBreak 54 import androidx.compose.ui.text.style.TextAlign 55 import androidx.compose.ui.tooling.preview.Preview 56 import androidx.compose.ui.unit.dp 57 import net.taler.common.Amount 58 import net.taler.common.CurrencySpecification 59 import net.taler.lib.android.canAppHandleUri 60 import net.taler.lib.android.copyToClipBoard 61 import net.taler.wallet.BottomInsetsSpacer 62 import net.taler.wallet.CURRENCY_BTC 63 import net.taler.wallet.R 64 import net.taler.wallet.compose.ShareButton 65 import net.taler.wallet.transactions.AccountRestriction 66 import net.taler.wallet.transactions.AmountType 67 import net.taler.wallet.transactions.TransactionAmountComposable 68 import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails 69 import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails.Status.Ok 70 import net.taler.wallet.withdraw.QrCodeSpec 71 import net.taler.wallet.withdraw.QrCodeSpec.Type.EpcQr 72 import net.taler.wallet.withdraw.QrCodeSpec.Type.SPC 73 import net.taler.wallet.withdraw.TransferData 74 75 sealed class TransferContext { 76 data object ManualWithdrawal : TransferContext() 77 data class DepositKycAuth(val debitPaytoUri: String) : TransferContext() 78 } 79 80 @Composable 81 fun ScreenTransfer( 82 modifier: Modifier = Modifier, 83 transfers: List<TransferData>, 84 spec: CurrencySpecification?, 85 showQrCodes: Boolean, 86 getQrCodes: (account: TransferData) -> List<QrCodeSpec>, 87 bankAppClick: ((transfer: TransferData) -> Unit)?, 88 shareClick: ((transfer: TransferData) -> Unit)?, 89 devMode: Boolean = false, 90 transferContext: TransferContext, 91 ) { 92 // TODO: show some placeholder 93 if (transfers.isEmpty()) return 94 95 val transfers = transfers.filter { 96 // TODO: in dev mode, show debug info when status is `Error' 97 it.withdrawalAccount.status == Ok 98 }.sortedByDescending { 99 it.withdrawalAccount.priority 100 } 101 102 val defaultTransfer = transfers[0] 103 var selectedTransfer by remember { mutableStateOf(defaultTransfer) } 104 val qrCodes = remember(selectedTransfer) { getQrCodes(selectedTransfer) } 105 val qrExpandedStates = remember(qrCodes) { 106 val map = mutableStateMapOf<QrCodeSpec, Boolean>() 107 qrCodes.forEach { 108 map[it] = false 109 } 110 map 111 } 112 113 LaunchedEffect(Unit) { 114 getQrCodes(defaultTransfer) 115 } 116 117 Column(modifier) { 118 if (transfers.size > 1) { 119 TransferAccountChooser( 120 accounts = transfers.map { it.withdrawalAccount }, 121 selectedAccount = selectedTransfer.withdrawalAccount, 122 onSelectAccount = { account -> 123 transfers.find { 124 it.withdrawalAccount.paytoUri == account.paytoUri 125 }?.let { selectedTransfer = it } 126 } 127 ) 128 } 129 130 val scrollState = rememberScrollState() 131 Column( 132 modifier = Modifier 133 .verticalScroll(scrollState), 134 horizontalAlignment = Alignment.CenterHorizontally, 135 ) { 136 if (showQrCodes) { 137 Text( 138 text = stringResource(R.string.withdraw_manual_qr_intro), 139 style = MaterialTheme.typography.bodyLarge, 140 modifier = Modifier 141 .padding( 142 vertical = 8.dp, 143 horizontal = 16.dp, 144 ) 145 ) 146 147 qrCodes.forEach { spec -> 148 PaytoQrCard( 149 expanded = qrExpandedStates[spec]!!, 150 setExpanded = { expanded -> 151 if (expanded) { // un-expand all others 152 qrExpandedStates.forEach { (k, _) -> 153 qrExpandedStates[k] = false 154 } 155 } 156 // expand only toggled one 157 qrExpandedStates[spec] = expanded 158 }, 159 qrCode = spec, 160 ) 161 } 162 163 BottomInsetsSpacer() 164 return 165 } 166 167 when (val transfer = selectedTransfer) { 168 is TransferData.Taler -> TransferTaler( 169 transfer = transfer, 170 transactionAmountEffective = transfer.amountEffective.withSpec(spec), 171 transferContext = transferContext, 172 ) 173 174 is TransferData.IBAN -> TransferIBAN( 175 transfer = transfer, 176 transactionAmountEffective = transfer.amountEffective.withSpec(spec), 177 transferContext = transferContext, 178 ) 179 180 is TransferData.Cyclos -> TransferCyclos( 181 transfer = transfer, 182 transactionAmountEffective = transfer.amountEffective.withSpec(spec), 183 ) 184 185 is TransferData.Bitcoin -> TransferBitcoin( 186 transfer = transfer, 187 ) 188 } 189 190 Spacer(Modifier.height(24.dp)) 191 192 if (devMode) { 193 val paytoUri = selectedTransfer.withdrawalAccount.paytoUri 194 if (bankAppClick != null && LocalContext.current.canAppHandleUri(paytoUri)) { 195 Button( 196 onClick = { bankAppClick(selectedTransfer) }, 197 modifier = Modifier 198 .padding(bottom = 16.dp), 199 ) { 200 Text(text = stringResource(R.string.withdraw_manual_ready_bank_button)) 201 } 202 } 203 204 if (shareClick != null) { 205 ShareButton( 206 content = selectedTransfer.withdrawalAccount.paytoUri, 207 modifier = Modifier 208 .padding(bottom = 16.dp), 209 ) 210 } 211 } 212 213 BottomInsetsSpacer() 214 } 215 } 216 } 217 218 @Composable 219 fun TransferStep( 220 index: Int, 221 description: String, 222 ) { 223 Text( 224 modifier = Modifier.padding( 225 top = 16.dp, 226 start = 6.dp, 227 end = 6.dp, 228 bottom = 6.dp, 229 ), 230 text = AnnotatedString.fromHtml( 231 stringResource( 232 R.string.withdraw_manual_step, 233 index, 234 description, 235 ) 236 ), 237 style = MaterialTheme.typography.bodyMedium, 238 ) 239 } 240 241 @Composable 242 fun DetailRow( 243 label: String, 244 content: String, 245 copy: Boolean = true, 246 characterBreak: Boolean = false, 247 ) { 248 val context = LocalContext.current 249 250 Column( 251 modifier = Modifier.fillMaxWidth(), 252 horizontalAlignment = Alignment.CenterHorizontally, 253 ) { 254 Text( 255 modifier = Modifier.padding(top = 16.dp, start = 6.dp, end = 6.dp), 256 text = label, 257 style = MaterialTheme.typography.bodyMedium, 258 ) 259 260 Row( 261 verticalAlignment = Alignment.CenterVertically, 262 ) { 263 Text( 264 modifier = Modifier.padding( 265 top = 8.dp, 266 start = 6.dp, 267 end = 6.dp, 268 ).weight(1f), 269 text = content, 270 style = if (characterBreak) { 271 MaterialTheme.typography.bodyLarge.copy( 272 lineBreak = LineBreak.Heading, 273 ) 274 } else MaterialTheme.typography.bodyLarge, 275 fontFamily = if (copy) FontFamily.Monospace else FontFamily.Default, 276 textAlign = TextAlign.Center, 277 ) 278 279 if (copy) { 280 TextButton( 281 onClick = { copyToClipBoard(context, label, content) }, 282 ) { 283 Icon( 284 Icons.Default.ContentCopy, 285 contentDescription = null, 286 modifier = Modifier.size(ButtonDefaults.IconSize), 287 ) 288 Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 289 Text(stringResource(R.string.copy)) 290 } 291 } 292 } 293 } 294 } 295 296 @Composable 297 fun WithdrawalAmountTransfer( 298 conversionAmountRaw: Amount, 299 ) { 300 Column( 301 modifier = Modifier.fillMaxWidth(), 302 horizontalAlignment = Alignment.CenterHorizontally, 303 ) { 304 TransactionAmountComposable( 305 label = stringResource(R.string.amount_transfer), 306 amount = conversionAmountRaw, 307 amountType = AmountType.Neutral, 308 context = LocalContext.current, 309 copy = true, 310 ) 311 } 312 } 313 314 @Composable 315 fun TransferAccountChooser( 316 modifier: Modifier = Modifier, 317 accounts: List<WithdrawalExchangeAccountDetails>, 318 selectedAccount: WithdrawalExchangeAccountDetails, 319 onSelectAccount: (account: WithdrawalExchangeAccountDetails) -> Unit, 320 ) { 321 val selectedIndex = accounts.indexOfFirst { 322 it.paytoUri == selectedAccount.paytoUri 323 } 324 325 ScrollableTabRow( 326 selectedTabIndex = selectedIndex, 327 modifier = modifier, 328 edgePadding = 8.dp, 329 ) { 330 accounts.forEachIndexed { index, account -> 331 Tab( 332 selected = selectedAccount.paytoUri == account.paytoUri, 333 onClick = { onSelectAccount(account) }, 334 text = { 335 if (!account.bankLabel.isNullOrEmpty()) { 336 Text(account.bankLabel) 337 } else if (account.currencySpecification?.name != null) { 338 Text(stringResource( 339 R.string.withdraw_account_currency, 340 index + 1, 341 account.currencySpecification.name, 342 )) 343 } else if (account.transferAmount?.currency != null) { 344 Text(stringResource( 345 R.string.withdraw_account_currency, 346 index + 1, 347 account.transferAmount.currency, 348 )) 349 } else Text(stringResource(R.string.withdraw_account, index + 1)) 350 }, 351 ) 352 } 353 } 354 } 355 356 @Preview 357 @Composable 358 fun ScreenTransferPreview( 359 showQrCodes: Boolean = false, 360 transferContext: TransferContext = TransferContext.ManualWithdrawal, 361 ) { 362 Surface { 363 ScreenTransfer( 364 transfers = listOf( 365 TransferData.IBAN( 366 iban = "ASDQWEASDZXCASDQWE", 367 subject = "Taler Withdrawal P2T19EXRBY4B145JRNZ8CQTD7TCS03JE9VZRCEVKVWCP930P56WG", 368 amountRaw = Amount("KUDOS", 10, 0), 369 amountEffective = Amount("KUDOS", 9, 5), 370 transferAmount = Amount("KUDOS", 10, 0), 371 receiverTown = "Biel/Bienne", 372 receiverPostalCode = "2500", 373 withdrawalAccount = WithdrawalExchangeAccountDetails( 374 paytoUri = "https://taler.net/kudos", 375 transferAmount = Amount("KUDOS", 10, 0), 376 status = Ok, 377 currencySpecification = CurrencySpecification( 378 "KUDOS", 379 numFractionalInputDigits = 2, 380 numFractionalNormalDigits = 2, 381 numFractionalTrailingZeroDigits = 2, 382 altUnitNames = emptyMap(), 383 ), 384 creditRestrictions = listOf( 385 AccountRestriction.RegexAccount( 386 paytoRegex = "CH", 387 humanHint = "Only Swiss bank accounts", 388 humanHintI18n = emptyMap(), 389 ), 390 AccountRestriction.RegexAccount( 391 paytoRegex = "DE", 392 humanHint = "Only European bank accounts", 393 humanHintI18n = emptyMap(), 394 ) 395 ) 396 ), 397 ), 398 TransferData.Cyclos( 399 host = "demo.cyclos.org/abc", 400 account = "1234567890", 401 receiverName = "Taler Wire", 402 subject = "Taler Withdrawal P2T19EXRBY4B145JRNZ8CQTD7TCS03JE9VZRCEVKVWCP930P56WG", 403 amountRaw = Amount("IU", 10, 0), 404 amountEffective = Amount("IU", 9, 5), 405 transferAmount = Amount("IU", 10, 0), 406 withdrawalAccount = WithdrawalExchangeAccountDetails( 407 paytoUri = "https://taler.net/cyclos", 408 transferAmount = Amount("IU", 10, 0), 409 status = Ok, 410 ), 411 ), 412 TransferData.Bitcoin( 413 account = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", 414 segwitAddresses = listOf( 415 "bc1qqleages8702xvg9qcyu02yclst24xurdrynvxq", 416 "bc1qsleagehks96u7jmqrzcf0fw80ea5g57qm3m84c" 417 ), 418 subject = "0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00", 419 amountRaw = Amount(CURRENCY_BTC, 0, 14000000), 420 amountEffective = Amount(CURRENCY_BTC, 0, 14000000), 421 transferAmount = Amount("KUDOS", 10, 0), 422 withdrawalAccount = WithdrawalExchangeAccountDetails( 423 paytoUri = "https://taler.net/btc", 424 transferAmount = Amount("BTC", 0, 14000000), 425 status = Ok, 426 currencySpecification = CurrencySpecification( 427 "Bitcoin", 428 numFractionalInputDigits = 2, 429 numFractionalNormalDigits = 2, 430 numFractionalTrailingZeroDigits = 2, 431 altUnitNames = emptyMap(), 432 ), 433 ), 434 ), 435 ), 436 spec = null, 437 bankAppClick = {}, 438 shareClick = {}, 439 showQrCodes = showQrCodes, 440 getQrCodes = { 441 listOf( 442 QrCodeSpec(EpcQr, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0"), 443 QrCodeSpec(SPC, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0") 444 ) 445 }, 446 transferContext = transferContext, 447 ) 448 } 449 } 450 451 @Preview 452 @Composable 453 fun ScreenTransferKycAuthPreview() { 454 ScreenTransferPreview(transferContext = TransferContext.DepositKycAuth("CH120912")) 455 } 456 457 @Preview 458 @Composable 459 fun ScreenTransferQRPreview() { 460 ScreenTransferPreview(true) 461 }