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:
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>