taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit f61ff2dd9f3b42ccb9037aed97071f755a8cc41e
parent 47f91f31f7d2e658979121f5e5590c02791577fa
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 27 Jun 2024 14:04:55 -0600

[wallet] Display payment QR codes in manual withdrawal

bug 0008957

Diffstat:
Awallet/src/main/java/net/taler/wallet/compose/ExpandableCard.kt | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt | 61++++++++++++++++++++++++++++++++-----------------------------
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt | 50+++++++++++++++++++++++++++++++++++++++++++++-----
Mwallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt | 6++++++
Mwallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mwallet/src/main/res/values/strings.xml | 2++
6 files changed, 275 insertions(+), 37 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/compose/ExpandableCard.kt b/wallet/src/main/java/net/taler/wallet/compose/ExpandableCard.kt @@ -0,0 +1,114 @@ +/* + * This file is part of GNU Taler + * (C) 2024 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.compose + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExpandableCard( + modifier: Modifier = Modifier, + expanded: Boolean = false, + setExpanded: (expanded: Boolean) -> Unit, + header: @Composable RowScope.() -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val rotationState by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "Rotation state of expand icon button", + ) + + OutlinedCard( + modifier = modifier.padding(8.dp), + onClick = { setExpanded(!expanded) } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() // edit animation here + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 4.dp, bottom = 4.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + header() + + IconButton( + modifier = Modifier.rotate(rotationState), + onClick = { setExpanded(!expanded) } + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Drop Down Arrow" + ) + } + } + + if (expanded) { + content() + } + } + } +} + +@Preview +@Composable +fun ExpandableCardPreview() { + TalerSurface { + var expanded by remember { mutableStateOf(true) } + ExpandableCard( + expanded = expanded, + setExpanded = { expanded = it }, + header = { Text("Swiss QR") }, + content = { + QrCodeUriComposable( + talerUri = "taler://withdraw-exchange", + clipBoardLabel = "", + showContents = false, + ) + } + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt @@ -60,6 +60,7 @@ fun ColumnScope.QrCodeUriComposable( talerUri: String, clipBoardLabel: String, buttonText: String = stringResource(R.string.copy), + showContents: Boolean = true, inBetween: (@Composable ColumnScope.() -> Unit)? = null, ) { val qrCodeSize = getQrCodeSize() @@ -74,43 +75,45 @@ fun ColumnScope.QrCodeUriComposable( modifier = Modifier .size(qrCodeSize) .align(CenterHorizontally) - .padding(vertical = 8.dp), + .padding(vertical = if (showContents) 8.dp else 0.dp), bitmap = qrCode, contentDescription = stringResource(id = R.string.button_scan_qr_code), ) } if (inBetween != null) inBetween() val scrollState = rememberScrollState() - Box(modifier = Modifier.padding(16.dp)) { - Text( - modifier = Modifier.horizontalScroll(scrollState), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodyLarge, - text = talerUri, - ) - } - Row( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - CopyToClipboardButton( - label = clipBoardLabel, - content = talerUri, - buttonText = buttonText, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + if (showContents) { + Box(modifier = Modifier.padding(16.dp)) { + Text( + modifier = Modifier.horizontalScroll(scrollState), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyLarge, + text = talerUri, ) - ) - ShareButton( - content = talerUri, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + } + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + CopyToClipboardButton( + label = clipBoardLabel, + content = talerUri, + buttonText = buttonText, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) ) - ) + ShareButton( + content = talerUri, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } } } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -23,6 +23,7 @@ import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import net.taler.common.Amount import net.taler.common.Bech32 @@ -161,6 +162,28 @@ data class AcceptManualWithdrawalResponse( val transactionId: String, ) +@Serializable +data class GetQrCodesForPaytoResponse( + val codes: List<QrCodeSpec>, +) + +@Serializable +data class QrCodeSpec( + val type: Type = Type.Unknown, + val qrContent: String, +) { + @Serializable + enum class Type { + Unknown, + + @SerialName("epc-qr") + EpcQr, + + @SerialName("spc") + SPC, + } +} + class WithdrawManager( private val api: WalletBackendApi, private val scope: CoroutineScope, @@ -169,6 +192,8 @@ class WithdrawManager( val withdrawStatus = MutableLiveData<WithdrawStatus>() val testWithdrawalStatus = MutableLiveData<WithdrawTestStatus>() + val qrCodes = MutableLiveData<List<QrCodeSpec>>() + var exchangeFees: ExchangeFees? = null private set @@ -331,11 +356,26 @@ class WithdrawManager( }.onError { handleError("acceptManualWithdrawal", it) }.onSuccess { response -> - withdrawStatus.value = createManualTransferRequired( - status = status, - response = response, - ) + scope.launch { + withdrawStatus.value = createManualTransferRequired( + status = status, + response = response, + ) + } + } + } + + fun getQrCodesForPayto(uri: String) = scope.launch { + var codes = emptyList<QrCodeSpec>() + api.request("getQrCodesForPayto", GetQrCodesForPaytoResponse.serializer()) { + put("paytoUri", uri) + }.onError { error -> + handleError("getQrCodesForPayto", error) + }.onSuccess { response -> + codes = response.codes } + + qrCodes.value = codes } private fun handleError(operation: String, error: TalerErrorInfo) { @@ -378,7 +418,7 @@ fun createManualTransferRequired( subject = reserve, amountRaw = amountRaw, amountEffective = amountEffective, - withdrawalAccount = it.copy(paytoUri = uri.toString()) + withdrawalAccount = it.copy(paytoUri = uri.toString()), ) } else if (uri.authority.equals("x-taler-bank", true)) { TransferData.Taler( diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt @@ -21,6 +21,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -65,8 +67,12 @@ class ManualWithdrawSuccessFragment : Fragment() { setContent { TalerSurface { + val qrCodes by withdrawManager.qrCodes.observeAsState() + ScreenTransfer( status = status, + qrCodes = qrCodes ?: emptyList(), + getQrCodes = { withdrawManager.getQrCodesForPayto(it.paytoUri) }, spec = balanceManager.getSpecForCurrency(status.transactionAmountRaw.currency), bankAppClick = { onBankAppClick(it) }, shareClick = { onShareClick(it) }, diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt @@ -32,7 +32,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -45,23 +47,30 @@ 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.wallet.CURRENCY_BTC import net.taler.wallet.R -import net.taler.common.CurrencySpecification +import net.taler.wallet.compose.ExpandableCard +import net.taler.wallet.compose.QrCodeUriComposable import net.taler.common.canAppHandleUri import net.taler.wallet.compose.ShareButton import net.taler.wallet.compose.copyToClipBoard import net.taler.wallet.transactions.AmountType import net.taler.wallet.transactions.TransactionAmountComposable import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails -import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails.Status.* +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 import net.taler.wallet.withdraw.WithdrawStatus @Composable fun ScreenTransfer( status: WithdrawStatus.ManualTransferRequired, + qrCodes: List<QrCodeSpec>, spec: CurrencySpecification?, + getQrCodes: (account: WithdrawalExchangeAccountDetails) -> Unit, bankAppClick: ((transfer: TransferData) -> Unit)?, shareClick: ((transfer: TransferData) -> Unit)?, ) { @@ -77,6 +86,17 @@ fun ScreenTransfer( val defaultTransfer = transfers[0] var selectedTransfer by remember { mutableStateOf(defaultTransfer) } + val qrExpandedStates = remember(qrCodes) { + val map = mutableStateMapOf<QrCodeSpec, Boolean>() + qrCodes.forEach { + map[it] = false + } + map + } + + LaunchedEffect(Unit) { + getQrCodes(defaultTransfer.withdrawalAccount) + } Column { if (status.withdrawalTransfers.size > 1) { @@ -86,7 +106,10 @@ fun ScreenTransfer( onSelectAccount = { account -> status.withdrawalTransfers.find { it.withdrawalAccount.paytoUri == account.paytoUri - }?.let { selectedTransfer = it } + }?.let { + selectedTransfer = it + getQrCodes(it.withdrawalAccount) + } } ) } @@ -137,6 +160,22 @@ fun ScreenTransfer( .padding(bottom = 16.dp), ) } + + qrCodes.forEach { spec -> + QrCard( + 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, + ) + } } } } @@ -267,6 +306,34 @@ fun TransferAccountChooser( } } +@Composable +fun QrCard( + expanded: Boolean, + setExpanded: (expanded: Boolean) -> Unit, + qrCode: QrCodeSpec, +) { + val label = when(qrCode.type) { + EpcQr -> stringResource(R.string.withdraw_manual_qr_epc) + SPC -> stringResource(R.string.withdraw_manual_qr_spc) + else -> return + } + + ExpandableCard( + expanded = expanded, + setExpanded = setExpanded, + header = { + Text(label, style = MaterialTheme.typography.titleMedium) + }, + content = { + QrCodeUriComposable( + talerUri = qrCode.qrContent, + clipBoardLabel = label, + showContents = false, + ) + }, + ) +} + @Preview @Composable fun ScreenTransferPreview() { @@ -323,6 +390,11 @@ fun ScreenTransferPreview() { spec = null, bankAppClick = {}, shareClick = {}, + 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") + ), + getQrCodes = {}, ) } } \ No newline at end of file diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -238,6 +238,8 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="withdraw_manual_bitcoin_intro">Now make a split transaction with the following three outputs.</string> <string name="withdraw_manual_check_fees">Check fees</string> <string name="withdraw_manual_payment_options">Payment options supported by %1$s:\n\n%2$s</string> + <string name="withdraw_manual_qr_epc">EPC QR</string> + <string name="withdraw_manual_qr_spc">Swiss QR</string> <string name="withdraw_manual_ready_account">Account</string> <string name="withdraw_manual_ready_bank_button">Open in banking app</string> <string name="withdraw_manual_ready_details_intro">Bank transfer details</string>