ScreenTransfer.kt
/*
* This file is part of GNU Taler
* (C) 2025 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.wallet.transfer
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.taler.common.Amount
import net.taler.common.CurrencySpecification
import net.taler.lib.android.canAppHandleUri
import net.taler.lib.android.copyToClipBoard
import net.taler.wallet.BottomInsetsSpacer
import net.taler.wallet.CURRENCY_BTC
import net.taler.wallet.R
import net.taler.wallet.compose.ShareButton
import net.taler.wallet.transactions.AccountRestriction
import net.taler.wallet.transactions.AmountType
import net.taler.wallet.transactions.TransactionAmountComposable
import net.taler.wallet.transactions.TransferOption
import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails
import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails.Status.Ok
import net.taler.wallet.withdraw.QrCodeSpec
import net.taler.wallet.withdraw.QrCodeSpec.Type.EpcQr
import net.taler.wallet.withdraw.QrCodeSpec.Type.SPC
import net.taler.wallet.withdraw.TransferData
sealed class TransferContext {
data object ManualWithdrawal : TransferContext()
data class DepositKycAuth(val debitPaytoUri: String) : TransferContext()
}
fun TransferOption.subjectLabelResId(): Int = when (this) {
is TransferOption.SwissQrBill -> R.string.withdraw_manual_ready_subject_ch_qr
else -> R.string.withdraw_manual_ready_subject
}
@Composable
fun ScreenTransfer(
modifier: Modifier = Modifier,
transfer: TransferData,
showQrCodes: Boolean,
bankAppClick: ((transfer: TransferData) -> Unit)?,
shareClick: ((transfer: TransferData) -> Unit)?,
devMode: Boolean = false,
transferContext: TransferContext,
) {
val qrCodes = transfer.transferOption.qrCodes
val qrExpandedStates = remember(qrCodes) {
mutableStateMapOf<QrCodeSpec, Boolean>()
}
val scrollState = rememberScrollState()
Column(
modifier = modifier
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (showQrCodes) {
Text(
text = stringResource(R.string.withdraw_manual_qr_intro),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.padding(
vertical = 8.dp,
horizontal = 16.dp,
)
)
qrCodes.forEach { spec ->
PaytoQrCard(
expanded = qrExpandedStates[spec]!!,
setExpanded = { expanded ->
if (expanded) { // un-expand all others
qrExpandedStates.forEach { (k, _) ->
qrExpandedStates[k] = false
}
}
// expand only toggled one
qrExpandedStates[spec] = expanded
},
qrCode = spec,
)
}
BottomInsetsSpacer()
return
}
when (transfer) {
is TransferData.Taler -> TransferTaler(
transfer = transfer,
transactionAmountEffective = transfer.amountEffective,
transferContext = transferContext,
)
is TransferData.IBAN -> TransferIBAN(
transfer = transfer,
transactionAmountEffective = transfer.amountEffective,
transferContext = transferContext,
)
is TransferData.Cyclos -> TransferCyclos(
transfer = transfer,
transactionAmountEffective = transfer.amountEffective,
)
is TransferData.Bitcoin -> TransferBitcoin(
transfer = transfer,
)
}
Spacer(Modifier.height(24.dp))
if (devMode) {
val paytoUri = transfer.withdrawalAccount.paytoUri
if (bankAppClick != null && LocalContext.current.canAppHandleUri(paytoUri)) {
Button(
onClick = { bankAppClick(transfer) },
modifier = Modifier
.padding(bottom = 16.dp),
) {
Text(text = stringResource(R.string.withdraw_manual_ready_bank_button))
}
}
if (shareClick != null) {
ShareButton(
content = transfer.withdrawalAccount.paytoUri,
modifier = Modifier
.padding(bottom = 16.dp),
)
}
}
BottomInsetsSpacer()
}
}
@Composable
fun TransferStep(
index: Int,
description: String,
) {
Text(
modifier = Modifier.padding(
top = 16.dp,
start = 6.dp,
end = 6.dp,
bottom = 6.dp,
),
text = AnnotatedString.fromHtml(
stringResource(
R.string.withdraw_manual_step,
index,
description,
)
),
style = MaterialTheme.typography.bodyMedium,
)
}
@Composable
fun DetailRow(
label: String,
content: String,
copy: Boolean = true,
characterBreak: Boolean = false,
) {
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier.padding(top = 16.dp, start = 6.dp, end = 6.dp),
text = label,
style = MaterialTheme.typography.bodyMedium,
)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.padding(
top = 8.dp,
start = 6.dp,
end = 6.dp,
).weight(1f),
text = content,
style = if (characterBreak) {
MaterialTheme.typography.bodyLarge.copy(
lineBreak = LineBreak.Heading,
)
} else MaterialTheme.typography.bodyLarge,
fontFamily = if (copy) FontFamily.Monospace else FontFamily.Default,
textAlign = TextAlign.Center,
)
if (copy) {
TextButton(
onClick = { copyToClipBoard(context, label, content) },
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.copy))
}
}
}
}
}
@Composable
fun WithdrawalAmountTransfer(
conversionAmountRaw: Amount,
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TransactionAmountComposable(
label = stringResource(R.string.amount_transfer),
amount = conversionAmountRaw,
amountType = AmountType.Neutral,
context = LocalContext.current,
copy = true,
)
}
}
private val qrCodes = listOf(
QrCodeSpec(EpcQr, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0"),
QrCodeSpec(SPC, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0"),
)
private val transferOptions = listOf(
TransferOption.Payto(
"payto://",
qrCodes = listOf(
QrCodeSpec(EpcQr, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0"),
QrCodeSpec(SPC, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0"),
),
),
)
@Preview
@Composable
fun ScreenTransferIBANPreview(
showQrCodes: Boolean = false,
transferContext: TransferContext = TransferContext.ManualWithdrawal,
) {
Surface {
ScreenTransfer(
transfer = TransferData.IBAN(
iban = "ASDQWEASDZXCASDQWE",
subject = "Taler Withdrawal P2T19EXRBY4B145JRNZ8CQTD7TCS03JE9VZRCEVKVWCP930P56WG",
amountRaw = Amount("KUDOS", 10, 0),
amountEffective = Amount("KUDOS", 9, 5),
transferAmount = Amount("KUDOS", 10, 0),
receiverTown = "Biel/Bienne",
receiverPostalCode = "2500",
transferOption = transferOptions.first(),
withdrawalAccount = WithdrawalExchangeAccountDetails(
paytoUri = "https://taler.net/kudos",
transferAmount = Amount("KUDOS", 10, 0),
transferOptions = transferOptions,
status = Ok,
currencySpecification = CurrencySpecification(
"KUDOS",
numFractionalInputDigits = 2,
numFractionalNormalDigits = 2,
numFractionalTrailingZeroDigits = 2,
altUnitNames = emptyMap(),
),
creditRestrictions = listOf(
AccountRestriction.RegexAccount(
paytoRegex = "CH",
humanHint = "Only Swiss bank accounts",
humanHintI18n = emptyMap(),
),
AccountRestriction.RegexAccount(
paytoRegex = "DE",
humanHint = "Only European bank accounts",
humanHintI18n = emptyMap(),
)
)
),
),
bankAppClick = {},
shareClick = {},
showQrCodes = showQrCodes,
transferContext = transferContext,
)
}
}
@Preview
@Composable
fun ScreenTransferCyclosPreview(
showQrCodes: Boolean = false,
transferContext: TransferContext = TransferContext.ManualWithdrawal,
) {
Surface {
ScreenTransfer(
transfer = TransferData.Cyclos(
host = "demo.cyclos.org/abc",
account = "1234567890",
receiverName = "Taler Wire",
subject = "Taler Withdrawal P2T19EXRBY4B145JRNZ8CQTD7TCS03JE9VZRCEVKVWCP930P56WG",
amountRaw = Amount("IU", 10, 0),
amountEffective = Amount("IU", 9, 5),
transferAmount = Amount("IU", 10, 0),
transferOption = transferOptions.first(),
withdrawalAccount = WithdrawalExchangeAccountDetails(
paytoUri = "https://taler.net/cyclos",
transferAmount = Amount("IU", 10, 0),
transferOptions = transferOptions,
status = Ok,
),
),
bankAppClick = {},
shareClick = {},
showQrCodes = showQrCodes,
transferContext = transferContext,
)
}
}
@Preview
@Composable
fun ScreenTransferBitcoinPreview(
showQrCodes: Boolean = false,
transferContext: TransferContext = TransferContext.ManualWithdrawal,
) {
Surface {
ScreenTransfer(
transfer = TransferData.Bitcoin(
account = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
segwitAddresses = listOf(
"bc1qqleages8702xvg9qcyu02yclst24xurdrynvxq",
"bc1qsleagehks96u7jmqrzcf0fw80ea5g57qm3m84c"
),
subject = "0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00",
amountRaw = Amount(CURRENCY_BTC, 0, 14000000),
amountEffective = Amount(CURRENCY_BTC, 0, 14000000),
transferAmount = Amount("KUDOS", 10, 0),
transferOption = transferOptions.first(),
withdrawalAccount = WithdrawalExchangeAccountDetails(
paytoUri = "https://taler.net/btc",
transferAmount = Amount("BTC", 0, 14000000),
transferOptions = transferOptions,
status = Ok,
currencySpecification = CurrencySpecification(
"Bitcoin",
numFractionalInputDigits = 2,
numFractionalNormalDigits = 2,
numFractionalTrailingZeroDigits = 2,
altUnitNames = emptyMap(),
),
),
),
bankAppClick = {},
shareClick = {},
showQrCodes = showQrCodes,
transferContext = transferContext,
)
}
}
@Preview
@Composable
fun ScreenTransferKycAuthPreview() {
ScreenTransferIBANPreview(transferContext = TransferContext.DepositKycAuth("CH120912"))
}
@Preview
@Composable
fun ScreenTransferQRPreview() {
ScreenTransferIBANPreview(true)
}