[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[taler-taler-android] branch master updated (d7196a0 -> e7e2763)
From: |
gnunet |
Subject: |
[taler-taler-android] branch master updated (d7196a0 -> e7e2763) |
Date: |
Tue, 26 Sep 2023 18:31:20 +0200 |
This is an automated email from the git hooks/post-receive script.
torsten-grote pushed a change to branch master
in repository taler-android.
from d7196a0 [wallet] don't crash when transaction list has no
bindingAdapterPosition
new cb6d836 [wallet] Initial version of template support
new b38d99f [wallet] Improved templates UX and PoS confirmation codes
new aa1be46 [wallet] first cleanup of payment template work
new 6734a0f [wallet] Improve internal logic of templates
new 0fee8e1 [wallet] add some potential TODOs for pay templates
new 68a3c7c [wallet] Additional refactoring of pay templates
new 9837f4b [wallet] fixes for templates parsing and UI
new ed7f772 [wallet] Refactor amount input into single composable
new 138ea13 [wallet] Support 0.x fractions in AmountInputField
new 66d96c5 [wallet] Improved AmountInputField with a VisualTransformation
new 1624b99 [wallet] fix: AmountInputField reacts to external changes
new f967d32 [wallet] simplify AmountInputField
new 559bfbe [wallet] simplify pay templates
new e7e2763 [wallet] Fix back navigation after template prompt.
The 14 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../src/main/java/net/taler/common/Amount.kt | 1 +
.../src/main/java/net/taler/wallet/MainActivity.kt | 4 +
.../java/net/taler/wallet/ReceiveFundsFragment.kt | 27 +---
.../java/net/taler/wallet/SendFundsFragment.kt | 35 ++--
.../net/taler/wallet/compose/AmountInputField.kt | 169 +++++++++++++++++++
.../net/taler/wallet/deposit/PayToUriFragment.kt | 39 +++--
.../taler/wallet/payment/PayTemplateComposable.kt | 180 +++++++++++++++++++++
.../taler/wallet/payment/PayTemplateFragment.kt | 123 ++++++++++++++
.../wallet/payment/PayTemplateOrderComposable.kt | 179 ++++++++++++++++++++
.../net/taler/wallet/payment/PaymentManager.kt | 25 +++
.../wallet/payment/TransactionPaymentComposable.kt | 6 +
.../net/taler/wallet/transactions/Transactions.kt | 1 +
wallet/src/main/res/navigation/nav_graph.xml | 17 ++
wallet/src/main/res/values/strings.xml | 4 +
14 files changed, 745 insertions(+), 65 deletions(-)
create mode 100644
wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt
create mode 100644
wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt
create mode 100644
wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt
create mode 100644
wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt
b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt
index 4861568..5fb36fa 100644
--- a/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt
+++ b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt
@@ -90,6 +90,7 @@ public data class Amount(
}
public fun isValidAmountStr(str: String): Boolean {
+ if (str.count { it == '.' } > 1) return false
val split = str.split(".")
try {
checkValue(split[0].toLongOrNull())
diff --git a/wallet/src/main/java/net/taler/wallet/MainActivity.kt
b/wallet/src/main/java/net/taler/wallet/MainActivity.kt
index a49890e..cfeeb31 100644
--- a/wallet/src/main/java/net/taler/wallet/MainActivity.kt
+++ b/wallet/src/main/java/net/taler/wallet/MainActivity.kt
@@ -290,6 +290,10 @@ class MainActivity : AppCompatActivity(),
OnNavigationItemSelectedListener,
nav.navigate(R.id.action_global_prompt_push_payment)
model.peerManager.preparePeerPushCredit(u2)
}
+ action.startsWith("pay-template/", ignoreCase = true) -> {
+ val bundle = bundleOf("uri" to u2)
+ nav.navigate(R.id.action_global_prompt_pay_template,
bundle)
+ }
else -> {
showError(R.string.error_unsupported_uri, "From:
$from\nURI: $u2")
}
diff --git a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt
b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt
index dbff6ae..e560a71 100644
--- a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt
@@ -29,12 +29,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -46,7 +44,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.KeyboardType.Companion.Decimal
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
@@ -55,7 +52,7 @@ import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import net.taler.common.Amount
-import net.taler.common.Amount.Companion.isValidAmountStr
+import net.taler.wallet.compose.AmountInputField
import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.exchanges.ExchangeItem
@@ -127,35 +124,27 @@ private fun ReceiveFundsIntro(
.fillMaxWidth()
.verticalScroll(scrollState),
) {
- var text by rememberSaveable { mutableStateOf("") }
+ var text by rememberSaveable { mutableStateOf("0") }
var isError by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(16.dp),
) {
- OutlinedTextField(
+ AmountInputField(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
value = text,
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType =
Decimal),
onValueChange = { input ->
isError = false
- val filtered = input.filter { it.isDigit() || it == '.' }
- if (filtered.endsWith('.') || isValidAmountStr(filtered))
text = filtered
+ text = input
+ },
+ label = { Text(stringResource(R.string.receive_amount)) },
+ supportingText = {
+ if (isError)
Text(stringResource(R.string.receive_amount_invalid))
},
isError = isError,
- label = {
- if (isError) {
- Text(
- stringResource(R.string.receive_amount_invalid),
- color = MaterialTheme.colorScheme.error,
- )
- } else {
- Text(stringResource(R.string.receive_amount))
- }
- }
)
Text(
modifier = Modifier,
diff --git a/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt
b/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt
index c2680d5..b33e53b 100644
--- a/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt
@@ -27,12 +27,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -44,7 +42,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
@@ -52,7 +49,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import net.taler.common.Amount
-import net.taler.common.Amount.Companion.isValidAmountStr
+import net.taler.wallet.compose.AmountInputField
import net.taler.wallet.compose.TalerSurface
class SendFundsFragment : Fragment() {
@@ -108,7 +105,7 @@ private fun SendFundsIntro(
.fillMaxWidth()
.verticalScroll(scrollState),
) {
- var text by rememberSaveable { mutableStateOf("") }
+ var text by rememberSaveable { mutableStateOf("0") }
var isError by rememberSaveable { mutableStateOf(false) }
var insufficientBalance by rememberSaveable { mutableStateOf(false) }
Row(
@@ -116,34 +113,20 @@ private fun SendFundsIntro(
modifier = Modifier
.padding(16.dp),
) {
- OutlinedTextField(
+ AmountInputField(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
value = text,
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType =
KeyboardType.Decimal),
onValueChange = { input ->
isError = false
- insufficientBalance = false
- val filtered = input.filter { it.isDigit() || it == '.' }
- if (filtered.endsWith('.') || isValidAmountStr(filtered))
text = filtered
+ text = input
},
- isError = isError || insufficientBalance,
- label = {
- if (isError) {
- Text(
- stringResource(R.string.receive_amount_invalid),
- color = MaterialTheme.colorScheme.error,
- )
- } else if (insufficientBalance) {
- Text(
-
stringResource(R.string.payment_balance_insufficient),
- color = MaterialTheme.colorScheme.error,
- )
- } else {
- Text(stringResource(R.string.send_amount))
- }
- }
+ label = { Text(stringResource(R.string.send_amount)) },
+ supportingText = {
+ if (isError)
Text(stringResource(R.string.receive_amount_invalid))
+ },
+ isError = isError,
)
Text(
modifier = Modifier,
diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt
b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt
new file mode 100644
index 0000000..0229ec5
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt
@@ -0,0 +1,169 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 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.foundation.layout.Arrangement.Absolute.spacedBy
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.OutlinedTextField
+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.Modifier
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import java.text.DecimalFormat
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AmountInputField(
+ value: String,
+ onValueChange: (value: String) -> Unit,
+ modifier: Modifier = Modifier,
+ label: @Composable (() -> Unit)? = null,
+ supportingText: @Composable (() -> Unit)? = null,
+ isError: Boolean = false,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+) {
+ val decimalSeparator =
DecimalFormat().decimalFormatSymbols.decimalSeparator
+ var amountInput by remember { mutableStateOf(value) }
+
+ // React to external changes
+ val amountValue = remember(amountInput, value) {
+ transformOutput(amountInput, decimalSeparator, '.').let {
+ if (value != it) value else amountInput
+ }
+ }
+
+ OutlinedTextField(
+ value = amountValue,
+ onValueChange = { input ->
+ val filtered = transformOutput(input, decimalSeparator, '.')
+ if (Amount.isValidAmountStr(filtered)) {
+ amountInput = transformInput(input, decimalSeparator, '.')
+ // tmpIn = input
+ onValueChange(filtered)
+ }
+ },
+ modifier = modifier,
+ textStyle = LocalTextStyle.current.copy(fontFamily =
FontFamily.Monospace),
+ label = label,
+ supportingText = supportingText,
+ isError = isError,
+ visualTransformation =
AmountInputVisualTransformation(decimalSeparator),
+ keyboardOptions = KeyboardOptions.Default.copy(keyboardType =
KeyboardType.Decimal),
+ keyboardActions = keyboardActions,
+ singleLine = true,
+ maxLines = 1,
+ )
+}
+
+private class AmountInputVisualTransformation(
+ private val decimalSeparator: Char,
+) : VisualTransformation {
+
+ override fun filter(text: AnnotatedString): TransformedText {
+ val value = text.text
+ val output = transformOutput(value, '.', decimalSeparator)
+ val newText = AnnotatedString(output)
+ return TransformedText(
+ newText, CursorOffsetMapping(
+ unmaskedText = text.toString(),
+ maskedText = newText.toString().replace(decimalSeparator, '.'),
+ )
+ )
+ }
+
+ private class CursorOffsetMapping(
+ private val unmaskedText: String,
+ private val maskedText: String,
+ ) : OffsetMapping {
+ override fun originalToTransformed(offset: Int) = when {
+ unmaskedText.startsWith('.') -> if (offset == 0) 0 else (offset +
1) // ".x" -> "0.x"
+ else -> offset
+ }
+
+ override fun transformedToOriginal(offset: Int) = when {
+ unmaskedText == "" -> 0 // "0" -> ""
+ unmaskedText == "." -> if (offset < 1) 0 else 1 // "0.0" -> "."
+ unmaskedText.startsWith('.') -> if (offset < 1) 0 else (offset -
1) // "0.x" -> ".x"
+ unmaskedText.endsWith('.') && offset == maskedText.length ->
offset - 1 // "x.0" -> "x."
+ else -> offset // "x" -> "x"
+ }
+ }
+}
+
+private fun transformInput(
+ input: String,
+ inputDecimalSeparator: Char = '.',
+ outputDecimalSeparator: Char = '.',
+) = input.trim().replace(inputDecimalSeparator, outputDecimalSeparator)
+
+private fun transformOutput(
+ input: String,
+ inputDecimalSeparator: Char = '.',
+ outputDecimalSeparator: Char = '.',
+) = transformInput(input, inputDecimalSeparator, outputDecimalSeparator).let {
+ when {
+ it.isEmpty() -> "0"
+ it == "$outputDecimalSeparator" -> "0${outputDecimalSeparator}0"
+ it.startsWith(outputDecimalSeparator) -> "0$it"
+ it.endsWith(outputDecimalSeparator) -> "${it}0"
+ else -> it
+ }
+}
+
+@Preview
+@Composable
+fun AmountInputFieldPreview() {
+ var value by remember { mutableStateOf("0") }
+ TalerSurface {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ verticalArrangement = spacedBy(16.dp),
+ ) {
+ AmountInputField(
+ value = value,
+ onValueChange = { value = it },
+ label = { Text("Amount input:") },
+ supportingText = { Text("This amount is nice.") },
+ )
+ AmountInputField(
+ value = value,
+ onValueChange = { value = it },
+ label = { Text("Error in amount input:") },
+ supportingText = { Text("Amount is invalid.") },
+ isError = true,
+ )
+ }
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt
b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt
index c8b5b6e..4bc91e1 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
@@ -49,13 +48,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
@@ -66,6 +65,7 @@ import net.taler.common.Amount
import net.taler.wallet.AmountResult
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
+import net.taler.wallet.compose.AmountInputField
import net.taler.wallet.compose.TalerSurface
class PayToUriFragment : Fragment() {
@@ -131,30 +131,27 @@ private fun PayToComposable(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
- var amountText by rememberSaveable { mutableStateOf("") }
+ var amountText by rememberSaveable { mutableStateOf("0") }
var amountError by rememberSaveable { mutableStateOf("") }
var currency by rememberSaveable { mutableStateOf(currencies[0]) }
val focusRequester = remember { FocusRequester() }
- OutlinedTextField(
- modifier = Modifier
- .focusRequester(focusRequester),
+ AmountInputField(
+ modifier = Modifier.focusRequester(focusRequester),
value = amountText,
onValueChange = { input ->
amountError = ""
amountText = input
},
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType =
KeyboardType.Decimal),
- singleLine = true,
+ label = { Text(stringResource(R.string.send_amount)) },
+ supportingText = {
+ if (amountError.isNotBlank()) Text(amountError)
+ },
isError = amountError.isNotBlank(),
- label = {
- if (amountError.isBlank()) {
- Text(stringResource(R.string.send_amount))
- } else {
- Text(amountError, color = MaterialTheme.colorScheme.error)
- }
- }
)
CurrencyDropdown(
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentSize(Center),
currencies = currencies,
onCurrencyChanged = { c -> currency = c },
)
@@ -189,17 +186,19 @@ private fun PayToComposable(
fun CurrencyDropdown(
currencies: List<String>,
onCurrencyChanged: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ initialCurrency: String? = null,
+ readOnly: Boolean = false,
) {
- var selectedIndex by remember { mutableStateOf(0) }
+ val initialIndex = currencies.indexOf(initialCurrency).let { if (it < 0) 0
else it }
+ var selectedIndex by remember { mutableStateOf(initialIndex) }
var expanded by remember { mutableStateOf(false) }
Box(
- modifier = Modifier
- .fillMaxSize()
- .wrapContentSize(Alignment.Center),
+ modifier = modifier,
) {
OutlinedTextField(
modifier = Modifier
- .clickable(onClick = { expanded = true }),
+ .clickable(onClick = { if (!readOnly) expanded = true }),
value = currencies[selectedIndex],
onValueChange = { },
readOnly = true,
diff --git
a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt
b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt
new file mode 100644
index 0000000..815f463
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt
@@ -0,0 +1,180 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 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.payment
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.Center
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.taler.common.Amount
+import net.taler.common.ContractTerms
+import net.taler.wallet.AmountResult
+import net.taler.wallet.R
+import net.taler.wallet.compose.TalerSurface
+
+sealed class AmountFieldStatus {
+ object FixedAmount : AmountFieldStatus()
+ class Default(
+ val amountStr: String? = null,
+ val currency: String? = null,
+ ) : AmountFieldStatus()
+
+ object Invalid : AmountFieldStatus()
+}
+
+@Composable
+fun PayTemplateComposable(
+ defaultSummary: String?,
+ amountStatus: AmountFieldStatus,
+ currencies: List<String>,
+ payStatus: PayStatus,
+ onCreateAmount: (String, String) -> AmountResult,
+ onSubmit: (summary: String?, amount: Amount?) -> Unit,
+ onError: (resId: Int) -> Unit,
+) {
+ // If wallet is empty, there's no way the user can pay something
+ if (amountStatus is AmountFieldStatus.Invalid) {
+ PayTemplateError(stringResource(R.string.receive_amount_invalid))
+ } else if (currencies.isEmpty()) {
+ PayTemplateError(stringResource(R.string.payment_balance_insufficient))
+ } else when (payStatus) {
+ is PayStatus.None -> PayTemplateOrderComposable(
+ currencies = currencies,
+ defaultSummary = defaultSummary,
+ amountStatus = amountStatus,
+ onCreateAmount = onCreateAmount,
+ onError = onError,
+ onSubmit = onSubmit,
+ )
+
+ is PayStatus.Loading -> PayTemplateLoading()
+ is PayStatus.AlreadyPaid ->
PayTemplateError(stringResource(R.string.payment_already_paid))
+ is PayStatus.InsufficientBalance ->
PayTemplateError(stringResource(R.string.payment_balance_insufficient))
+ is PayStatus.Error -> {} // handled in fragment will show bottom sheet
FIXME white page?
+ is PayStatus.Prepared -> {} // handled in fragment, will redirect
+ is PayStatus.Success -> {} // handled by other UI flow, no need for
content here
+ }
+}
+
+@Composable
+fun PayTemplateError(message: String) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Center,
+ ) {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+}
+
+@Composable
+fun PayTemplateLoading() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Center,
+ ) {
+ CircularProgressIndicator()
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateLoadingPreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ defaultSummary = "Donation",
+ amountStatus = AmountFieldStatus.Default("20", "ARS"),
+ payStatus = PayStatus.Loading,
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency,
text))
+ },
+ onSubmit = { _, _ -> },
+ onError = { _ -> },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateInsufficientBalancePreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ defaultSummary = "Donation",
+ amountStatus = AmountFieldStatus.Default("20", "ARS"),
+ payStatus = PayStatus.InsufficientBalance(
+ ContractTerms(
+ "test",
+ amount = Amount.zero("TESTKUDOS"),
+ products = emptyList()
+ ), Amount.zero("TESTKUDOS")
+ ),
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency,
text))
+ },
+ onSubmit = { _, _ -> },
+ onError = { _ -> },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateAlreadyPaidPreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ defaultSummary = "Donation",
+ amountStatus = AmountFieldStatus.Default("20", "ARS"),
+ payStatus = PayStatus.AlreadyPaid,
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency,
text))
+ },
+ onSubmit = { _, _ -> },
+ onError = { _ -> },
+ )
+ }
+}
+
+
+@Preview
+@Composable
+fun PayTemplateNoCurrenciesPreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ defaultSummary = "Donation",
+ amountStatus = AmountFieldStatus.Default("20", "ARS"),
+ payStatus = PayStatus.None,
+ currencies = emptyList(),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency,
text))
+ },
+ onSubmit = { _, _ -> },
+ onError = { _ -> },
+ )
+ }
+}
diff --git
a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt
b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt
new file mode 100644
index 0000000..64cb2c1
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt
@@ -0,0 +1,123 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 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.payment
+
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.ui.platform.ComposeView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.asFlow
+import androidx.navigation.NavOptions
+import androidx.navigation.fragment.findNavController
+import net.taler.common.Amount
+import net.taler.common.showError
+import net.taler.wallet.MainViewModel
+import net.taler.wallet.R
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.compose.collectAsStateLifecycleAware
+import net.taler.wallet.showError
+
+class PayTemplateFragment : Fragment() {
+
+ private val model: MainViewModel by activityViewModels()
+ private lateinit var uriString: String
+ private lateinit var uri: Uri
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ uriString = arguments?.getString("uri") ?: error("no amount passed")
+ uri = Uri.parse(uriString)
+
+ val defaultSummary = uri.getQueryParameter("summary")
+ val defaultAmount = uri.getQueryParameter("amount")
+ val amountFieldStatus = getAmountFieldStatus(defaultAmount)
+
+ val payStatusFlow = model.paymentManager.payStatus.asFlow()
+
+ return ComposeView(requireContext()).apply {
+ setContent {
+ val payStatus =
payStatusFlow.collectAsStateLifecycleAware(initial = PayStatus.None)
+ TalerSurface {
+ PayTemplateComposable(
+ currencies = model.getCurrencies(),
+ defaultSummary = defaultSummary,
+ amountStatus = amountFieldStatus,
+ payStatus = payStatus.value,
+ onCreateAmount = model::createAmount,
+ onSubmit = this@PayTemplateFragment::createOrder,
+ onError = { this@PayTemplateFragment.showError(it) },
+ )
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ if (uri.queryParameterNames?.isEmpty() == true) {
+ createOrder(null, null)
+ }
+
+ model.paymentManager.payStatus.observe(viewLifecycleOwner) { payStatus
->
+ when (payStatus) {
+ is PayStatus.Prepared -> {
+ val navOptions = NavOptions.Builder()
+ .setPopUpTo(R.id.nav_main, true)
+ .build()
+
findNavController().navigate(R.id.action_promptPayTemplate_to_promptPayment,
null, navOptions)
+ }
+
+ is PayStatus.Error -> {
+ if (model.devMode.value == true) {
+ showError(payStatus.error)
+ } else {
+ showError(R.string.payment_template_error,
payStatus.error.userFacingMsg)
+ }
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ private fun getAmountFieldStatus(defaultAmount: String?):
AmountFieldStatus {
+ return if (defaultAmount == null) {
+ AmountFieldStatus.FixedAmount
+ } else if (defaultAmount.isBlank()) {
+ AmountFieldStatus.Default()
+ } else {
+ val parts = defaultAmount.split(":")
+ when (parts.size) {
+ 0 -> AmountFieldStatus.Default()
+ 1 -> AmountFieldStatus.Default(currency = parts[0])
+ 2 -> AmountFieldStatus.Default(parts[1], parts[0])
+ else -> AmountFieldStatus.Invalid
+ }
+ }
+ }
+
+ private fun createOrder(summary: String?, amount: Amount?) {
+ model.paymentManager.preparePayForTemplate(uriString, summary, amount)
+ }
+}
diff --git
a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt
b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt
new file mode 100644
index 0000000..1524faf
--- /dev/null
+++
b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt
@@ -0,0 +1,179 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 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.payment
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedTextField
+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.Companion.End
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.wallet.AmountResult
+import net.taler.wallet.R
+import net.taler.wallet.compose.AmountInputField
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.deposit.CurrencyDropdown
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PayTemplateOrderComposable(
+ currencies: List<String>, // assumed to have size > 0
+ defaultSummary: String? = null,
+ amountStatus: AmountFieldStatus,
+ onCreateAmount: (String, String) -> AmountResult,
+ onError: (msgRes: Int) -> Unit,
+ onSubmit: (summary: String?, amount: Amount?) -> Unit,
+) {
+ val amountDefault = amountStatus as? AmountFieldStatus.Default
+
+ var summary by remember { mutableStateOf(defaultSummary) }
+ var currency by remember { mutableStateOf(amountDefault?.currency ?:
currencies[0]) }
+ var amount by remember { mutableStateOf(amountDefault?.amountStr ?: "0") }
+
+ Column(horizontalAlignment = End) {
+ if (defaultSummary != null) OutlinedTextField(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth(),
+ value = summary ?: "",
+ isError = summary.isNullOrBlank(),
+ onValueChange = { summary = it },
+ singleLine = true,
+ label = {
Text(stringResource(R.string.withdraw_manual_ready_subject)) },
+ )
+ if (amountDefault != null) AmountField(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ amount = amount,
+ currency = currency,
+ currencies = currencies,
+ fixedCurrency = (amountStatus as?
AmountFieldStatus.Default)?.currency != null,
+ onAmountChosen = { a, c ->
+ amount = a
+ currency = c
+ },
+ )
+ Button(
+ modifier = Modifier.padding(16.dp),
+ enabled = defaultSummary == null || !summary.isNullOrBlank(),
+ onClick = {
+ when (val res = onCreateAmount(amount, currency)) {
+ is AmountResult.InsufficientBalance ->
onError(R.string.payment_balance_insufficient)
+ is AmountResult.InvalidAmount ->
onError(R.string.receive_amount_invalid)
+ is AmountResult.Success -> onSubmit(summary, res.amount)
+ }
+ },
+ ) {
+ Text(stringResource(R.string.payment_create_order))
+ }
+ }
+}
+
+@Composable
+private fun AmountField(
+ modifier: Modifier = Modifier,
+ currencies: List<String>,
+ fixedCurrency: Boolean,
+ amount: String,
+ currency: String,
+ onAmountChosen: (amount: String, currency: String) -> Unit,
+) {
+ Row(
+ modifier = modifier,
+ ) {
+ AmountInputField(
+ modifier = Modifier
+ .padding(end = 16.dp)
+ .weight(1f),
+ value = amount,
+ onValueChange = { onAmountChosen(it, currency) },
+ label = { Text(stringResource(R.string.send_amount)) }
+ )
+ CurrencyDropdown(
+ modifier = Modifier.weight(1f),
+ initialCurrency = currency,
+ currencies = currencies,
+ onCurrencyChanged = { onAmountChosen(amount, it) },
+ readOnly = fixedCurrency,
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateDefaultPreview() {
+ TalerSurface {
+ PayTemplateOrderComposable(
+ defaultSummary = "Donation",
+ amountStatus = AmountFieldStatus.Default("20", "ARS"),
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency,
text))
+ },
+ onSubmit = { _, _ -> },
+ onError = { },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateFixedAmountPreview() {
+ TalerSurface {
+ PayTemplateOrderComposable(
+ defaultSummary = "default summary",
+ amountStatus = AmountFieldStatus.FixedAmount,
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency,
text))
+ },
+ onSubmit = { _, _ -> },
+ onError = { },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateBlankSubjectPreview() {
+ TalerSurface {
+ PayTemplateOrderComposable(
+ defaultSummary = "",
+ amountStatus = AmountFieldStatus.FixedAmount,
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency,
text))
+ },
+ onSubmit = { _, _ -> },
+ onError = { },
+ )
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
index c280304..3a3069c 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
@@ -32,6 +32,7 @@ import net.taler.wallet.payment.PayStatus.InsufficientBalance
import net.taler.wallet.payment.PreparePayResponse.AlreadyConfirmedResponse
import net.taler.wallet.payment.PreparePayResponse.InsufficientBalanceResponse
import net.taler.wallet.payment.PreparePayResponse.PaymentPossibleResponse
+import org.json.JSONObject
val REGEX_PRODUCT_IMAGE =
Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$")
@@ -78,6 +79,7 @@ class PaymentManager(
response.contractTerms,
response.amountRaw
)
+
is AlreadyConfirmedResponse -> AlreadyPaid
}
}
@@ -102,6 +104,29 @@ class PaymentManager(
resetPayStatus()
}
+ fun preparePayForTemplate(url: String, summary: String?, amount: Amount?)
= scope.launch {
+ mPayStatus.value = PayStatus.Loading
+ api.request("preparePayForTemplate", PreparePayResponse.serializer()) {
+ put("talerPayTemplateUri", url)
+ put("templateParams", JSONObject().apply {
+ summary?.let { put("summary", it) }
+ amount?.let { put("amount", it.toJSONString()) }
+ })
+ }.onError {
+ handleError("preparePayForTemplate", it)
+ }.onSuccess { response ->
+ mPayStatus.value = when (response) {
+ is PaymentPossibleResponse -> response.toPayStatusPrepared()
+ is InsufficientBalanceResponse -> InsufficientBalance(
+ contractTerms = response.contractTerms,
+ amountRaw = response.amountRaw,
+ )
+
+ is AlreadyConfirmedResponse -> AlreadyPaid
+ }
+ }
+ }
+
internal fun abortProposal(proposalId: String) = scope.launch {
Log.i(TAG, "aborting proposal")
api.request<Unit>("abortProposal") {
diff --git
a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
index e6a65d1..c08bc76 100644
---
a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
+++
b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
@@ -88,6 +88,12 @@ fun TransactionPaymentComposable(
amount = t.amountEffective - t.amountRaw,
amountType = AmountType.Negative,
)
+ if (t.posConfirmation != null) {
+ TransactionInfoComposable(
+ label = stringResource(id =
R.string.payment_confirmation_code),
+ info = t.posConfirmation,
+ )
+ }
PurchaseDetails(info = t.info) {
onFulfill(t.info.fulfillmentUrl ?: "")
}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
index c6be73a..536d433 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
@@ -218,6 +218,7 @@ class TransactionPayment(
override val error: TalerErrorInfo? = null,
override val amountRaw: Amount,
override val amountEffective: Amount,
+ val posConfirmation: String? = null,
) : Transaction() {
override val icon = R.drawable.ic_cash_usd_outline
override val detailPageNav = R.id.action_nav_transactions_detail_payment
diff --git a/wallet/src/main/res/navigation/nav_graph.xml
b/wallet/src/main/res/navigation/nav_graph.xml
index bc35f34..99f4895 100644
--- a/wallet/src/main/res/navigation/nav_graph.xml
+++ b/wallet/src/main/res/navigation/nav_graph.xml
@@ -214,6 +214,19 @@
app:popUpTo="@id/nav_main" />
</fragment>
+ <fragment
+ android:id="@+id/promptPayTemplate"
+ android:name="net.taler.wallet.payment.PayTemplateFragment"
+ android:label="@string/payment_pay_template_title">
+ <action
+ android:id="@+id/action_promptPayTemplate_to_promptPayment"
+ app:destination="@+id/promptPayment"
+ app:popUpTo="@id/nav_main" />
+ <argument
+ android:name="uri"
+ app:argType="string" />
+ </fragment>
+
<fragment
android:id="@+id/nav_transactions"
android:name="net.taler.wallet.transactions.TransactionsFragment"
@@ -371,6 +384,10 @@
android:id="@+id/action_global_prompt_push_payment"
app:destination="@id/promptPushPayment" />
+ <action
+ android:id="@+id/action_global_prompt_pay_template"
+ app:destination="@id/promptPayTemplate" />
+
<action
android:id="@+id/action_global_pending_operations"
app:destination="@id/nav_pending_operations" />
diff --git a/wallet/src/main/res/values/strings.xml
b/wallet/src/main/res/values/strings.xml
index 17e4e24..e037055 100644
--- a/wallet/src/main/res/values/strings.xml
+++ b/wallet/src/main/res/values/strings.xml
@@ -131,6 +131,10 @@ GNU Taler is immune against many types of fraud, such as
phishing of credit card
<string name="payment_initiated">Payment initiated</string>
<string name="payment_already_paid_title">Already paid</string>
<string name="payment_already_paid">You\'ve already paid for this
purchase.</string>
+ <string name="payment_pay_template_title">Customize your order</string>
+ <string name="payment_create_order">Create order</string>
+ <string name="payment_confirmation_code">Confirmation code</string>
+ <string name="payment_template_error">Error creating order</string>
<string name="receive_amount">Amount to receive</string>
<string name="receive_amount_invalid">Amount invalid</string>
--
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.
- [taler-taler-android] branch master updated (d7196a0 -> e7e2763),
gnunet <=
- [taler-taler-android] 03/14: [wallet] first cleanup of payment template work, gnunet, 2023/09/26
- [taler-taler-android] 01/14: [wallet] Initial version of template support, gnunet, 2023/09/26
- [taler-taler-android] 10/14: [wallet] Improved AmountInputField with a VisualTransformation, gnunet, 2023/09/26
- [taler-taler-android] 11/14: [wallet] fix: AmountInputField reacts to external changes, gnunet, 2023/09/26
- [taler-taler-android] 05/14: [wallet] add some potential TODOs for pay templates, gnunet, 2023/09/26
- [taler-taler-android] 09/14: [wallet] Support 0.x fractions in AmountInputField, gnunet, 2023/09/26
- [taler-taler-android] 04/14: [wallet] Improve internal logic of templates, gnunet, 2023/09/26
- [taler-taler-android] 08/14: [wallet] Refactor amount input into single composable, gnunet, 2023/09/26
- [taler-taler-android] 02/14: [wallet] Improved templates UX and PoS confirmation codes, gnunet, 2023/09/26
- [taler-taler-android] 14/14: [wallet] Fix back navigation after template prompt., gnunet, 2023/09/26