taler-android

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

commit 7c80e8d552cc6eb7d7ae77bec10059c701471f04
parent e58c826fbfb734a5a054c52b49d4677f3086b734
Author: Iván Ávalos <avalos@disroot.org>
Date:   Tue,  3 Mar 2026 21:47:36 +0100

[wallet] fix #11164 (withdrawal transfer redesign)

Diffstat:
Mtaler-kotlin-android/src/main/java/net/taler/common/QrCodeManager.kt | 14+++++++++++---
Mwallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt | 13+++++--------
Mwallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt | 7++++---
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt | 18+++++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/transfer/PaytoQrCard.kt | 53++++++++++++++++++++++++++++++++---------------------
Mwallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt | 159++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mwallet/src/main/res/values/strings.xml | 3+++
7 files changed, 189 insertions(+), 78 deletions(-)

diff --git a/taler-kotlin-android/src/main/java/net/taler/common/QrCodeManager.kt b/taler-kotlin-android/src/main/java/net/taler/common/QrCodeManager.kt @@ -35,14 +35,21 @@ import com.google.zxing.EncodeHintType.MARGIN import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +enum class QrLogoSize(val size: Float) { + SMALL(0.15f), + MEDIUM(0.20f), + BIG(0.25f), +} + object QrCodeManager { fun makeQrCode( text: String, size: Int = 256, - margin: Int = 4, + margin: Int = 2, errorCorrection: ErrorCorrectionLevel = ErrorCorrectionLevel.M, centerLogo: Drawable? = null, + centerLogoSize: QrLogoSize = QrLogoSize.MEDIUM, drawBackground: Boolean = false, ): Bitmap { val qrCodeWriter = QRCodeWriter() @@ -61,7 +68,7 @@ object QrCodeManager { } return if (centerLogo != null) { - addCenteredLogo(bmp, centerLogo, drawBackground) + addCenteredLogo(bmp, centerLogo, centerLogoSize, drawBackground) } else { bmp } @@ -70,13 +77,14 @@ object QrCodeManager { private fun addCenteredLogo( qrBitmap: Bitmap, logoDrawable: Drawable, + logoSize: QrLogoSize = QrLogoSize.MEDIUM, drawBackground: Boolean = false, ): Bitmap { val result = qrBitmap.copy(ARGB_8888, true) val canvas = Canvas(result) val logoBitmap = drawableToBitmap(logoDrawable) - var logoMaxWidth = (result.width * 0.22f).toInt() + var logoMaxWidth = (result.width * logoSize.size).toInt() val logoAspectRatio = logoBitmap.width.toFloat() / logoBitmap.height.toFloat() var logoWidth = logoMaxWidth var logoHeight = (logoWidth / logoAspectRatio).toInt().coerceAtLeast(1) diff --git a/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt @@ -43,31 +43,27 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min import net.taler.common.QrCodeManager +import net.taler.common.QrLogoSize import net.taler.common.copyToClipBoard import net.taler.wallet.R -import net.taler.wallet.toImageBitmap -import kotlin.let @Composable fun ColumnScope.QrCodeUriComposable( + modifier: Modifier = Modifier, talerUri: String, clipBoardLabel: String, centerLogo: Drawable? = null, + centerLogoSize: QrLogoSize = QrLogoSize.MEDIUM, drawCenterLogoBackground: Boolean = false, buttonText: String = stringResource(R.string.copy), showContents: Boolean = true, @@ -80,12 +76,13 @@ fun ColumnScope.QrCodeUriComposable( talerUri, qrCodeSize.value.toInt(), centerLogo = centerLogo, + centerLogoSize = centerLogoSize, drawBackground = drawCenterLogoBackground, ).asImageBitmap() } Box( - Modifier + modifier .fillMaxWidth() .aspectRatio(1f) .padding(bottom = if (showContents) 8.dp else 0.dp), diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt @@ -26,11 +26,9 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -38,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import net.taler.common.Amount import net.taler.common.CurrencySpecification +import net.taler.common.QrLogoSize import net.taler.common.Timestamp import net.taler.wallet.R import net.taler.wallet.balances.ScopeInfo @@ -113,7 +112,7 @@ fun ColumnScope.PeerQrCode( if (state == TransactionState(Pending) && state.minor != MergeKycRequired) { Text( modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.bodyLarge, text = stringResource(id = instructionResId, amount.toString()), textAlign = TextAlign.Center, ) @@ -121,9 +120,11 @@ fun ColumnScope.PeerQrCode( if (state.minor == Ready && talerUri != null) { Spacer(Modifier.height(8.dp)) QrCodeUriComposable( + modifier = Modifier.padding(horizontal = 16.dp), talerUri = talerUri, clipBoardLabel = "Push payment", centerLogo = ContextCompat.getDrawable(context, R.drawable.ic_taler_logo_qr), + centerLogoSize = QrLogoSize.BIG, drawCenterLogoBackground = true, buttonText = stringResource(id = R.string.copy), ) { diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware @@ -37,11 +38,26 @@ class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListene TalerSurface { val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() (t as? TransactionWithdrawal)?.let { tx -> + // show QR code only if withdrawal only contains one + val qrCode = remember(tx) { + (tx.withdrawalDetails as? WithdrawalDetails.ManualTransfer)?.let { details -> + if (details.exchangeCreditAccountDetails?.size == 1) { + val account0 = details.exchangeCreditAccountDetails[0] + val qrCodes = withdrawManager.getQrCodesForPayto(account0.paytoUri) + if (qrCodes.size == 1) qrCodes[0] + else null + } else null + } + } + TransactionWithdrawalComposable( t = tx, devMode = devMode, spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), - actionListener = this@TransactionWithdrawalFragment, + qrCode = qrCode, + onConfirmBank = { onActionButtonClicked(tx, ActionListener.Type.CONFIRM_WITH_BANK) }, + onConfirmManual = { onActionButtonClicked(tx, ActionListener.Type.CONFIRM_MANUAL) }, + onShowQrCodes = { onActionButtonClicked(tx, ActionListener.Type.SHOW_WIRE_QR) }, ) { onTransitionButtonClicked(tx, it) } diff --git a/wallet/src/main/java/net/taler/wallet/transfer/PaytoQrCard.kt b/wallet/src/main/java/net/taler/wallet/transfer/PaytoQrCard.kt @@ -16,6 +16,7 @@ package net.taler.wallet.transfer +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme @@ -23,10 +24,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import net.taler.common.QrLogoSize import net.taler.wallet.R import net.taler.wallet.compose.ExpandableCard import net.taler.wallet.compose.QrCodeUriComposable @@ -35,38 +36,48 @@ import net.taler.wallet.withdraw.QrCodeSpec.Type.EpcQr import net.taler.wallet.withdraw.QrCodeSpec.Type.SPC @Composable +fun ColumnScope.PaytoQrCode( + modifier: Modifier = Modifier, + qrCode: QrCodeSpec, +) { + val context = LocalContext.current + QrCodeUriComposable( + modifier = modifier, + talerUri = qrCode.qrContent, + clipBoardLabel = getQrCodeLabel(qrCode), + showContents = true, + shareAsQrCode = true, + centerLogoSize = QrLogoSize.SMALL, + centerLogo = when (qrCode.type) { + SPC -> ContextCompat.getDrawable(context, R.drawable.ic_swiss_qr) + else -> null + }, + ) +} + +@Composable fun PaytoQrCard( 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 - } - - val context = LocalContext.current - ExpandableCard( expanded = expanded, setExpanded = setExpanded, header = { - Text(label, style = MaterialTheme.typography.titleMedium) + Text(getQrCodeLabel(qrCode), + style = MaterialTheme.typography.titleMedium) }, content = { - QrCodeUriComposable( - talerUri = qrCode.qrContent, - clipBoardLabel = label, - showContents = true, - shareAsQrCode = true, - centerLogo = when (qrCode.type) { - SPC -> ContextCompat.getDrawable(context, R.drawable.ic_swiss_qr) - else -> null - }, - ) - + PaytoQrCode(qrCode = qrCode) Spacer(Modifier.height(8.dp)) }, ) +} + +@Composable +fun getQrCodeLabel(qr: QrCodeSpec) = when(qr.type) { + EpcQr -> stringResource(R.string.withdraw_manual_qr_epc) + SPC -> stringResource(R.string.withdraw_manual_qr_spc) + else -> stringResource(R.string.withdraw_manual_qr_unknown) } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt b/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt @@ -17,10 +17,19 @@ package net.taler.wallet.withdraw import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth 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.AccountBalance +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.QrCode +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 @@ -29,6 +38,7 @@ 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.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.common.Amount @@ -42,11 +52,8 @@ import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.cleanExchange -import net.taler.wallet.transactions.ActionButton -import net.taler.wallet.transactions.ActionListener import net.taler.wallet.transactions.AmountType import net.taler.wallet.transactions.ErrorTransactionButton -import net.taler.wallet.transactions.Transaction import net.taler.wallet.transactions.TransactionAction import net.taler.wallet.transactions.TransactionAction.Abort import net.taler.wallet.transactions.TransactionAction.Retry @@ -54,19 +61,24 @@ import net.taler.wallet.transactions.TransactionAction.Suspend import net.taler.wallet.transactions.TransactionAmountComposable import net.taler.wallet.transactions.TransactionInfoComposable import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionMinorState import net.taler.wallet.transactions.TransactionState import net.taler.wallet.transactions.TransactionStateComposable import net.taler.wallet.transactions.TransactionWithdrawal import net.taler.wallet.transactions.TransitionsComposable import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails +import net.taler.wallet.transfer.PaytoQrCode @Composable fun TransactionWithdrawalComposable( t: TransactionWithdrawal, devMode: Boolean, + qrCode: QrCodeSpec?, spec: CurrencySpecification?, - actionListener: ActionListener, + onConfirmBank: () -> Unit, + onConfirmManual: () -> Unit, + onShowQrCodes: () -> Unit, onTransition: (t: TransactionAction) -> Unit, ) { val scrollState = rememberScrollState() @@ -86,8 +98,6 @@ fun TransactionWithdrawalComposable( style = MaterialTheme.typography.bodyLarge, ) - ActionButton(tx = t, listener = actionListener) - if (t.amountRaw != t.amountEffective) { TransactionAmountComposable( label = stringResource(R.string.amount_chosen), @@ -111,6 +121,60 @@ fun TransactionWithdrawalComposable( amountType = AmountType.Positive, ) + if (t.txState.minor == TransactionMinorState.BankConfirmTransfer) { + Button(onClick = onConfirmBank) { + val label = stringResource(R.string.withdraw_button_confirm_bank) + Icon( + Icons.Default.Link, + label, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(label) + } + } else if (t.txState.minor == TransactionMinorState.ExchangeWaitReserve) { + Text( + text = stringResource(R.string.withdraw_manual_instruction_manual), + modifier = Modifier.padding(16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + + Button(onClick = onConfirmManual) { + Icon( + Icons.Default.AccountBalance, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.withdraw_manual_ready_details_intro)) + } + + if (qrCode != null) { + Text( + text = stringResource(R.string.withdraw_manual_instruction_qr), + modifier = Modifier.padding(16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + + PaytoQrCode( + modifier = Modifier.padding(horizontal = 16.dp), + qrCode = qrCode, + ) + } else { + Button(onClick = onShowQrCodes) { + Icon( + Icons.Default.QrCode, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.withdraw_manual_ready_details_qr)) + } + } + } + if (t.exchangeBaseUrl != null) { TransactionInfoComposable( label = stringResource(id = R.string.withdraw_exchange), @@ -129,46 +193,56 @@ fun TransactionWithdrawalComposable( } } -@Preview -@Composable -fun TransactionWithdrawalComposablePreview() { - val t = TransactionWithdrawal( - transactionId = "transactionId", - timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - txState = TransactionState(Pending), - txActions = listOf(Retry, Suspend, Abort), - exchangeBaseUrl = "https://exchange.demo.taler.net/", - withdrawalDetails = ManualTransfer( - exchangeCreditAccountDetails = listOf( - WithdrawalExchangeAccountDetails( - paytoUri = "payto://IBAN/1231231231", - transferAmount = Amount.fromJSONString("NETZBON:42.23"), - status = WithdrawalExchangeAccountDetails.Status.Ok, - currencySpecification = CurrencySpecification( - name = "NETZBON", - numFractionalInputDigits = 2, - numFractionalNormalDigits = 2, - numFractionalTrailingZeroDigits = 2, - altUnitNames = mapOf(0 to "NETZBON"), - ), +private val previewWithdrawalTx = TransactionWithdrawal( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending, TransactionMinorState.ExchangeWaitReserve), + txActions = listOf(Retry, Suspend, Abort), + exchangeBaseUrl = "https://exchange.demo.taler.net/", + withdrawalDetails = ManualTransfer( + exchangeCreditAccountDetails = listOf( + WithdrawalExchangeAccountDetails( + paytoUri = "payto://IBAN/1231231231", + transferAmount = Amount.fromJSONString("NETZBON:42.23"), + status = WithdrawalExchangeAccountDetails.Status.Ok, + currencySpecification = CurrencySpecification( + name = "NETZBON", + numFractionalInputDigits = 2, + numFractionalNormalDigits = 2, + numFractionalTrailingZeroDigits = 2, + altUnitNames = mapOf(0 to "NETZBON"), ), ), - reserveClosingDelay = RelativeTime.fromMillis(1000), ), - amountRaw = Amount.fromString("TESTKUDOS", "42.23"), - amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), - error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), - scopes = listOf(ScopeInfo.Exchange( - currency = "TESTKUDOS", - url = "exchange.test.taler.net", - )) - ) - - val listener = object : ActionListener { - override fun onActionButtonClicked(tx: Transaction, type: ActionListener.Type) {} - } + reserveClosingDelay = RelativeTime.fromMillis(1000), + ), + amountRaw = Amount.fromString("TESTKUDOS", "42.23"), + amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), + error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), + scopes = listOf(ScopeInfo.Exchange( + currency = "TESTKUDOS", + url = "exchange.test.taler.net", + )) +) +@Preview +@Composable +fun TransactionWithdrawalComposableSingleQrPreview() { Surface { - TransactionWithdrawalComposable(t, true, null, listener) {} + TransactionWithdrawalComposable(previewWithdrawalTx, true, + QrCodeSpec(QrCodeSpec.Type.SPC, "something"), + null, + {}, {}, {}, {}) } } + +@Preview +@Composable +fun TransactionWithdrawalComposableMultiQrPreview() { + Surface { + TransactionWithdrawalComposable(previewWithdrawalTx, true, + null, + null, + {}, {}, {}, {}) + } +} +\ No newline at end of file diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -364,9 +364,12 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="withdraw_fee">+%1$s withdrawal fees</string> <string name="withdraw_initiated">Withdrawal initiated</string> <string name="withdraw_manual_bitcoin_intro">Now make a split transaction with the following three outputs.</string> + <string name="withdraw_manual_instruction_manual">Follow the instructions to enter the transfer details into your banking app</string> + <string name="withdraw_manual_instruction_qr">Or share this QR code with your banking app to withdraw digital cash</string> <string name="withdraw_manual_qr_epc">EPC QR</string> <string name="withdraw_manual_qr_intro">If your banking software runs on another device, you can scan one of these QR codes:</string> <string name="withdraw_manual_qr_spc">Swiss QR bill</string> + <string name="withdraw_manual_qr_unknown">Unknown payment 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">Wire transfer instructions</string>