summaryrefslogtreecommitdiff
path: root/wallet/src/main/java/net/taler/wallet/payment
diff options
context:
space:
mode:
Diffstat (limited to 'wallet/src/main/java/net/taler/wallet/payment')
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt49
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt171
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt126
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt104
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt221
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt119
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt29
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt26
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt55
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt174
10 files changed, 955 insertions, 119 deletions
diff --git a/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt
deleted file mode 100644
index df2b2b8..0000000
--- a/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 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.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.fragment.app.Fragment
-import androidx.navigation.fragment.findNavController
-import net.taler.wallet.databinding.FragmentAlreadyPaidBinding
-
-/**
- * Display the message that the user already paid for the order
- * that the merchant is proposing.
- */
-class AlreadyPaidFragment : Fragment() {
-
- private lateinit var ui: FragmentAlreadyPaidBinding
-
- override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View? {
- ui = FragmentAlreadyPaidBinding.inflate(inflater, container, false)
- return ui.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- ui.backButton.setOnClickListener {
- findNavController().navigateUp()
- }
- }
-
-}
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..d744183
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt
@@ -0,0 +1,171 @@
+/*
+ * 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.foundation.layout.padding
+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 androidx.compose.ui.unit.dp
+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.LoadingScreen
+import net.taler.wallet.compose.TalerSurface
+
+@Composable
+fun PayTemplateComposable(
+ currencies: List<String>,
+ payStatus: PayStatus,
+ onCreateAmount: (String, String) -> AmountResult,
+ onSubmit: (params: TemplateParams) -> Unit,
+ onError: (resId: Int) -> Unit,
+) {
+ // If wallet is empty, there's no way the user can pay something
+ if (currencies.isEmpty()) {
+ PayTemplateError(stringResource(R.string.payment_balance_insufficient))
+ } else when (val p = payStatus) {
+ is PayStatus.Checked -> {
+ val usableCurrencies = currencies
+ .intersect(p.supportedCurrencies.toSet())
+ .toList()
+ if (usableCurrencies.isEmpty()) {
+ // If user doesn't have any supported currency, they can't pay either
+ PayTemplateError(stringResource(R.string.payment_balance_insufficient))
+ } else {
+ PayTemplateOrderComposable(
+ usableCurrencies = usableCurrencies,
+ templateDetails = p.details,
+ onCreateAmount = onCreateAmount,
+ onError = onError,
+ onSubmit = onSubmit,
+ )
+ }
+ }
+
+ is PayStatus.None, 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.Pending -> {
+ val error = p.error
+ PayTemplateError(if (error != null) {
+ stringResource(R.string.payment_error, error.userFacingMsg)
+ } else {
+ stringResource(R.string.payment_template_error)
+ })
+ }
+ 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.padding(16.dp).fillMaxSize(),
+ contentAlignment = Center,
+ ) {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+}
+
+@Composable
+fun PayTemplateLoading() {
+ LoadingScreen()
+}
+
+@Preview
+@Composable
+fun PayTemplateLoadingPreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ 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(
+ 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(widthDp = 300)
+@Composable
+fun PayTemplateAlreadyPaidPreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ payStatus = PayStatus.AlreadyPaid(transactionId = "transactionId"),
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { _ -> },
+ )
+ }
+}
+
+
+@Preview
+@Composable
+fun PayTemplateNoCurrenciesPreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ 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/PayTemplateDetails.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt
new file mode 100644
index 0000000..4c3f0a5
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt
@@ -0,0 +1,126 @@
+/*
+ * 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.payment
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.taler.common.Amount
+import net.taler.common.RelativeTime
+
+@Serializable
+data class TemplateContractDetails(
+ /**
+ * Human-readable summary for the template.
+ */
+ val summary: String? = null,
+
+ /**
+ * Required currency for payments to the template. The user may specify
+ * any amount, but it must be in this currency. This parameter is
+ * optional and should not be present if "amount" is given.
+ */
+ val currency: String? = null,
+
+ /**
+ * The price is imposed by the merchant and cannot be changed by the
+ * customer. This parameter is optional.
+ */
+ val amount: Amount? = null,
+
+ /**
+ * Minimum age buyer must have (in years). Default is 0.
+ */
+ @SerialName("minimum_age")
+ val minimumAge: Int,
+
+ /**
+ * The time the customer need to pay before his order will be deleted. It
+ * is deleted if the customer did not pay and if the duration is over.
+ */
+ @SerialName("pay_duration")
+ val payDuration: RelativeTime,
+)
+
+@Serializable
+data class TemplateContractDetailsDefaults(
+ val summary: String? = null,
+ val currency: String? = null,
+ val amount: Amount? = null,
+ @SerialName("minimum_age")
+ val minimumAge: Int? = null,
+)
+
+@Serializable
+class WalletTemplateDetails(
+ /**
+ * Hard-coded information about the contract terms for this template.
+ */
+ @SerialName("template_contract")
+ val templateContract: TemplateContractDetails,
+
+ /**
+ * Key-value pairs matching a subset of the fields from template_contract
+ * that are user-editable defaults for this template.
+ */
+ @SerialName("editable_defaults")
+ val editableDefaults: TemplateContractDetailsDefaults? = null,
+
+ /**
+ * Required currency for payments. Useful if no amount is specified in
+ * the template_contract but the user should be required to pay in a
+ * particular currency anyway. Merchant backends may reject requests if
+ * the template_contract or editable_defaults do specify an amount in a
+ * different currency. This parameter is optional.
+ */
+ @SerialName("required_currency")
+ val requiredCurrency: String? = null,
+) {
+ val defaultSummary get() = editableDefaults?.summary
+ ?: templateContract.summary
+
+ val defaultAmount get() = editableDefaults?.amount
+ ?: templateContract.amount
+
+ val defaultCurrency get() = requiredCurrency
+ ?: editableDefaults?.currency
+ ?: templateContract.currency
+
+ fun isSummaryEditable() = templateContract.summary == null
+
+ fun isAmountEditable() = templateContract.amount == null
+
+ fun isCurrencyEditable(usableCurrencies: List<String>) = isAmountEditable()
+ && requiredCurrency == null
+ && templateContract.currency == null
+ && usableCurrencies.size > 1
+
+ fun isTemplateEditable(usableCurrencies: List<String>) = isSummaryEditable()
+ || isAmountEditable()
+ || isCurrencyEditable(usableCurrencies)
+
+ // NOTE: it is important to nullify non-editable values!
+ fun toTemplateParams() = TemplateParams(
+ amount = if(isAmountEditable()) templateContract.amount else null,
+ summary = if(isSummaryEditable()) templateContract.summary else null,
+ )
+}
+
+@Serializable
+data class TemplateParams(
+ val amount: Amount? = null,
+ val summary: String? = null,
+) \ No newline at end of file
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..51c0bc0
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.fragment.findNavController
+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
+ private val currencies by lazy { model.getCurrencies() }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ uriString = arguments?.getString("uri") ?: error("no amount passed")
+ uri = Uri.parse(uriString)
+
+ val payStatusFlow = model.paymentManager.payStatus.asFlow()
+
+ return ComposeView(requireContext()).apply {
+ setContent {
+ val payStatus = payStatusFlow.collectAsStateLifecycleAware(initial = PayStatus.None)
+ TalerSurface {
+ PayTemplateComposable(
+ currencies = currencies,
+ 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)
+ checkTemplate()
+
+ model.paymentManager.payStatus.observe(viewLifecycleOwner) { payStatus ->
+ when (payStatus) {
+ is PayStatus.Prepared -> {
+ findNavController().navigate(R.id.action_promptPayTemplate_to_promptPayment)
+ }
+
+ is PayStatus.Pending -> if (payStatus.error != null && model.devMode.value == true) {
+ showError(payStatus.error)
+ }
+
+ is PayStatus.Checked -> {
+ val usableCurrencies = currencies
+ .intersect(payStatus.supportedCurrencies.toSet())
+ .toList()
+ if (!payStatus.details.isTemplateEditable(usableCurrencies)) {
+ createOrder(payStatus.details.toTemplateParams())
+ }
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ private fun checkTemplate() {
+ model.paymentManager.checkPayForTemplate(uriString)
+ }
+
+ private fun createOrder(params: TemplateParams) {
+ model.paymentManager.preparePayForTemplate(uriString, params)
+ }
+}
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..2febfbb
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt
@@ -0,0 +1,221 @@
+/*
+ * 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.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+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.common.RelativeTime
+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(ExperimentalComposeUiApi::class)
+@Composable
+fun PayTemplateOrderComposable(
+ usableCurrencies: List<String>, // non-empty intersection between the stored currencies and the ones supported by the merchant
+ templateDetails: WalletTemplateDetails,
+ onCreateAmount: (String, String) -> AmountResult,
+ onError: (msgRes: Int) -> Unit,
+ onSubmit: (params: TemplateParams) -> Unit,
+) {
+ val defaultSummary = templateDetails.defaultSummary
+ val defaultAmount = templateDetails.defaultAmount
+ val defaultCurrency = templateDetails.defaultCurrency
+
+ val summaryFocusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ var summary by remember { mutableStateOf(defaultSummary ?: "") }
+ var currency by remember { mutableStateOf(defaultCurrency ?: usableCurrencies[0]) }
+ var amount by remember { mutableStateOf(defaultAmount?.amountStr ?: "0") }
+
+ Column(horizontalAlignment = End) {
+ OutlinedTextField(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth()
+ .focusRequester(summaryFocusRequester)
+ .onFocusChanged {
+ if (it.isFocused) {
+ keyboardController?.show()
+ }
+ },
+ value = summary,
+ isError = templateDetails.isSummaryEditable() && summary.isBlank(),
+ onValueChange = { summary = it },
+ singleLine = true,
+ readOnly = !templateDetails.isSummaryEditable(),
+ label = { Text(stringResource(R.string.withdraw_manual_ready_subject)) },
+ )
+
+ AmountField(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ amount = amount,
+ currency = currency,
+ currencies = usableCurrencies,
+ readOnlyCurrency = !templateDetails.isCurrencyEditable(usableCurrencies),
+ readOnlyAmount = !templateDetails.isAmountEditable(),
+ onAmountChosen = { a, c ->
+ amount = a
+ currency = c
+ },
+ )
+
+ Button(
+ modifier = Modifier.padding(16.dp),
+ enabled = !templateDetails.isSummaryEditable() || summary.isNotBlank(),
+ onClick = {
+ when (val res = onCreateAmount(amount, currency)) {
+ is AmountResult.InsufficientBalance -> onError(R.string.payment_balance_insufficient)
+ is AmountResult.InvalidAmount -> onError(R.string.amount_invalid)
+ // NOTE: it is important to nullify non-editable values!
+ is AmountResult.Success -> onSubmit(TemplateParams(
+ summary = if (templateDetails.isSummaryEditable()) summary else null,
+ amount = if(templateDetails.isAmountEditable()) res.amount else null,
+ ))
+ }
+ },
+ ) {
+ Text(stringResource(R.string.payment_create_order))
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ if (templateDetails.isSummaryEditable()
+ && templateDetails.defaultSummary == null) {
+ summaryFocusRequester.requestFocus()
+ }
+ }
+}
+
+@Composable
+private fun AmountField(
+ modifier: Modifier = Modifier,
+ currencies: List<String>,
+ amount: String,
+ currency: String,
+ readOnlyAmount: Boolean = true,
+ readOnlyCurrency: Boolean = true,
+ 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.amount_send)) },
+ readOnly = readOnlyAmount,
+ )
+
+ CurrencyDropdown(
+ modifier = Modifier.weight(1f),
+ initialCurrency = currency,
+ currencies = currencies,
+ onCurrencyChanged = { onAmountChosen(amount, it) },
+ readOnly = readOnlyCurrency,
+ )
+ }
+}
+
+val defaultTemplateDetails = WalletTemplateDetails(
+ templateContract = TemplateContractDetails(
+ minimumAge = 18,
+ payDuration = RelativeTime.forever(),
+ ),
+ editableDefaults = TemplateContractDetailsDefaults(
+ summary = "Donation",
+ amount = Amount.fromJSONString("KUDOS:10.0"),
+ ),
+)
+
+@Preview
+@Composable
+fun PayTemplateDefaultPreview() {
+ TalerSurface {
+ PayTemplateOrderComposable(
+ templateDetails = defaultTemplateDetails,
+ usableCurrencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateFixedAmountPreview() {
+ TalerSurface {
+ PayTemplateOrderComposable(
+ templateDetails = defaultTemplateDetails,
+ usableCurrencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateBlankSubjectPreview() {
+ TalerSurface {
+ PayTemplateOrderComposable(
+ templateDetails = defaultTemplateDetails,
+ usableCurrencies = 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 34a8023..647c98c 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
@@ -22,40 +22,63 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import net.taler.common.Amount
import net.taler.common.ContractTerms
-import net.taler.lib.common.Amount
import net.taler.wallet.TAG
-import net.taler.wallet.backend.WalletBackendApi
+import net.taler.wallet.backend.BackendManager
import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.backend.WalletBackendApi
import net.taler.wallet.payment.PayStatus.AlreadyPaid
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+/=]+)$")
sealed class PayStatus {
- object None : PayStatus()
- object Loading : PayStatus()
+ data object None : PayStatus()
+ data object Loading : PayStatus()
data class Prepared(
val contractTerms: ContractTerms,
- val proposalId: String,
+ val transactionId: String,
val amountRaw: Amount,
- val amountEffective: Amount
+ val amountEffective: Amount,
+ ) : PayStatus()
+
+ data class Checked(
+ val details: WalletTemplateDetails,
+ val supportedCurrencies: List<String>,
) : PayStatus()
data class InsufficientBalance(
val contractTerms: ContractTerms,
- val amountRaw: Amount
+ val amountRaw: Amount,
+ ) : PayStatus()
+
+ data class AlreadyPaid(
+ val transactionId: String,
) : PayStatus()
- // TODO bring user to fulfilment URI
- object AlreadyPaid : PayStatus()
- data class Error(val error: String) : PayStatus()
- data class Success(val currency: String) : PayStatus()
+ data class Pending(
+ val transactionId: String? = null,
+ val error: TalerErrorInfo? = null,
+ ) : PayStatus()
+ data class Success(
+ val transactionId: String,
+ val currency: String,
+ ) : PayStatus()
}
+@Serializable
+data class CheckPayTemplateResponse(
+ val templateDetails: WalletTemplateDetails,
+ val supportedCurrencies: List<String>,
+)
+
class PaymentManager(
private val api: WalletBackendApi,
private val scope: CoroutineScope,
@@ -67,50 +90,76 @@ class PaymentManager(
@UiThread
fun preparePay(url: String) = scope.launch {
mPayStatus.value = PayStatus.Loading
- api.request("preparePay", PreparePayResponse.serializer()) {
+ api.request("preparePayForUri", PreparePayResponse.serializer()) {
put("talerPayUri", url)
}.onError {
- handleError("preparePay", it)
+ handleError("preparePayForUri", it)
}.onSuccess { response ->
mPayStatus.value = when (response) {
is PaymentPossibleResponse -> response.toPayStatusPrepared()
is InsufficientBalanceResponse -> InsufficientBalance(
- response.contractTerms,
- response.amountRaw
+ contractTerms = response.contractTerms,
+ amountRaw = response.amountRaw
+ )
+ is AlreadyConfirmedResponse -> AlreadyPaid(
+ transactionId = response.transactionId,
)
- is AlreadyConfirmedResponse -> AlreadyPaid
}
}
}
- fun confirmPay(proposalId: String, currency: String) = scope.launch {
+ fun confirmPay(transactionId: String, currency: String) = scope.launch {
api.request("confirmPay", ConfirmPayResult.serializer()) {
- put("proposalId", proposalId)
+ put("transactionId", transactionId)
}.onError {
handleError("confirmPay", it)
- }.onSuccess {
- mPayStatus.postValue(PayStatus.Success(currency))
+ }.onSuccess { response ->
+ mPayStatus.postValue(when (response) {
+ is ConfirmPayResult.Done -> PayStatus.Success(
+ transactionId = response.transactionId,
+ currency = currency,
+ )
+ is ConfirmPayResult.Pending -> PayStatus.Pending(
+ transactionId = response.transactionId,
+ error = response.lastError,
+ )
+ })
}
}
- @UiThread
- fun abortPay() {
- val ps = payStatus.value
- if (ps is PayStatus.Prepared) {
- abortProposal(ps.proposalId)
+ fun checkPayForTemplate(url: String) = scope.launch {
+ mPayStatus.value = PayStatus.Loading
+ api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) {
+ put("talerPayTemplateUri", url)
+ }.onError {
+ handleError("checkPayForTemplate", it)
+ }.onSuccess { response ->
+ mPayStatus.value = PayStatus.Checked(
+ details = response.templateDetails,
+ supportedCurrencies = response.supportedCurrencies,
+ )
}
- resetPayStatus()
}
- internal fun abortProposal(proposalId: String) = scope.launch {
- Log.i(TAG, "aborting proposal")
- api.request<Unit>("abortProposal") {
- put("proposalId", proposalId)
+ fun preparePayForTemplate(url: String, params: TemplateParams) = scope.launch {
+ mPayStatus.value = PayStatus.Loading
+ api.request("preparePayForTemplate", PreparePayResponse.serializer()) {
+ put("talerPayTemplateUri", url)
+ put("templateParams", JSONObject(BackendManager.json.encodeToString(params)))
}.onError {
- Log.e(TAG, "received error response to abortProposal")
- handleError("abortProposal", it)
- }.onSuccess {
- mPayStatus.postValue(PayStatus.None)
+ 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(
+ transactionId = response.transactionId,
+ )
+ }
}
}
@@ -121,7 +170,7 @@ class PaymentManager(
private fun handleError(operation: String, error: TalerErrorInfo) {
Log.e(TAG, "got $operation error result $error")
- mPayStatus.value = PayStatus.Error(error.userFacingMsg)
+ mPayStatus.value = PayStatus.Pending(error = error)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt
index 4b908b5..407f55f 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt
@@ -16,30 +16,30 @@
package net.taler.wallet.payment
+import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonClassDiscriminator
+import net.taler.common.Amount
import net.taler.common.ContractTerms
-import net.taler.lib.android.CustomClassDiscriminator
-import net.taler.lib.common.Amount
import net.taler.wallet.backend.TalerErrorInfo
+@OptIn(ExperimentalSerializationApi::class)
@Serializable
+@JsonClassDiscriminator("status")
sealed class PreparePayResponse {
- companion object : CustomClassDiscriminator {
- override val discriminator: String = "status"
- }
@Serializable
@SerialName("payment-possible")
data class PaymentPossibleResponse(
- val proposalId: String,
+ val transactionId: String,
val amountRaw: Amount,
val amountEffective: Amount,
val contractTerms: ContractTerms,
) : PreparePayResponse() {
fun toPayStatusPrepared() = PayStatus.Prepared(
contractTerms = contractTerms,
- proposalId = proposalId,
+ transactionId = transactionId,
amountRaw = amountRaw,
amountEffective = amountEffective,
)
@@ -48,7 +48,6 @@ sealed class PreparePayResponse {
@Serializable
@SerialName("insufficient-balance")
data class InsufficientBalanceResponse(
- val proposalId: String,
val amountRaw: Amount,
val contractTerms: ContractTerms,
) : PreparePayResponse()
@@ -56,13 +55,13 @@ sealed class PreparePayResponse {
@Serializable
@SerialName("already-confirmed")
data class AlreadyConfirmedResponse(
- val proposalId: String,
+ val transactionId: String,
/**
* Did the payment succeed?
*/
val paid: Boolean,
val amountRaw: Amount,
- val amountEffective: Amount,
+ val amountEffective: Amount? = null,
val contractTerms: ContractTerms,
) : PreparePayResponse()
}
@@ -71,9 +70,15 @@ sealed class PreparePayResponse {
sealed class ConfirmPayResult {
@Serializable
@SerialName("done")
- data class Done(val contractTerms: ContractTerms) : ConfirmPayResult()
+ data class Done(
+ val transactionId: String,
+ val contractTerms: ContractTerms,
+ ) : ConfirmPayResult()
@Serializable
@SerialName("pending")
- data class Pending(val lastError: TalerErrorInfo) : ConfirmPayResult()
+ data class Pending(
+ val transactionId: String,
+ val lastError: TalerErrorInfo? = null,
+ ) : ConfirmPayResult()
}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt
index 87b6387..289f0d7 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt
@@ -67,21 +67,25 @@ internal class ProductAdapter(private val listener: ProductImageClickListener) :
fun bind(product: ContractProduct) {
quantity.text = product.quantity.toString()
- if (product.image == null) {
+ val productImage = product.image
+ if (productImage == null) {
image.visibility = GONE
- } else {
- image.visibility = VISIBLE
- // product.image was validated before, so non-null below
- val match = REGEX_PRODUCT_IMAGE.matchEntire(product.image!!)!!
- val decodedString = Base64.decode(match.groups[2]!!.value, Base64.DEFAULT)
- val bitmap = decodeByteArray(decodedString, 0, decodedString.size)
- image.setImageBitmap(bitmap)
- image.setOnClickListener {
- listener.onImageClick(bitmap)
+ } else REGEX_PRODUCT_IMAGE.matchEntire(productImage)?.let { match ->
+ match.groups[2]?.value?.let { group ->
+ image.visibility = VISIBLE
+ val decodedString = Base64.decode(group, Base64.DEFAULT)
+ val bitmap = decodeByteArray(decodedString, 0, decodedString.size)
+ image.setImageBitmap(bitmap)
+ image.setOnClickListener {
+ listener.onImageClick(bitmap)
+ }
}
}
name.text = product.description
- price.text = product.totalPrice.toString()
+ price.visibility = product.totalPrice?.let {
+ price.text = it.toString()
+ VISIBLE
+ } ?: GONE
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt
index 700e158..1995f9d 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt
@@ -18,23 +18,29 @@ package net.taler.wallet.payment
import android.graphics.Bitmap
import android.os.Bundle
+import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
+import kotlinx.coroutines.launch
+import net.taler.common.Amount
import net.taler.common.ContractTerms
import net.taler.common.fadeIn
import net.taler.common.fadeOut
-import net.taler.lib.common.Amount
+import net.taler.common.showError
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
+import net.taler.wallet.TAG
import net.taler.wallet.databinding.FragmentPromptPaymentBinding
+import net.taler.wallet.showError
/**
* Show a payment and ask the user to accept/decline.
@@ -43,13 +49,14 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
private val model: MainViewModel by activityViewModels()
private val paymentManager by lazy { model.paymentManager }
+ private val transactionManager by lazy { model.transactionManager }
private lateinit var ui: FragmentPromptPaymentBinding
private val adapter = ProductAdapter(this)
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
- savedInstanceState: Bundle?
+ savedInstanceState: Bundle?,
): View {
ui = FragmentPromptPaymentBinding.inflate(inflater, container, false)
return ui.root
@@ -67,7 +74,15 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
override fun onDestroy() {
super.onDestroy()
if (!requireActivity().isChangingConfigurations) {
- paymentManager.abortPay()
+ val payStatus = paymentManager.payStatus.value as? PayStatus.Prepared ?: return
+ transactionManager.abortTransaction(payStatus.transactionId) { error ->
+ Log.e(TAG, "Error abortTransaction $error")
+ if (model.devMode.value == false) {
+ showError(error.userFacingMsg)
+ } else {
+ showError(error)
+ }
+ }
}
}
@@ -82,6 +97,8 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
private fun onPaymentStatusChanged(payStatus: PayStatus?) {
when (payStatus) {
+ null -> {}
+ is PayStatus.Checked -> {} // does not apply, only used for templates
is PayStatus.Prepared -> {
showLoading(false)
val fees = payStatus.amountEffective - payStatus.amountRaw
@@ -90,8 +107,8 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
ui.bottom.confirmButton.setOnClickListener {
model.showProgressBar.value = true
paymentManager.confirmPay(
- payStatus.proposalId,
- payStatus.contractTerms.amount.currency
+ transactionId = payStatus.transactionId,
+ currency = payStatus.contractTerms.amount.currency,
)
ui.bottom.confirmButton.fadeOut()
ui.bottom.confirmProgressBar.fadeIn()
@@ -106,19 +123,24 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
is PayStatus.Success -> {
showLoading(false)
paymentManager.resetPayStatus()
- findNavController().navigate(R.id.action_promptPayment_to_nav_main)
- model.showTransactions(payStatus.currency)
+ navigateToTransaction(payStatus.transactionId)
Snackbar.make(requireView(), R.string.payment_initiated, LENGTH_LONG).show()
}
is PayStatus.AlreadyPaid -> {
showLoading(false)
paymentManager.resetPayStatus()
- findNavController().navigate(R.id.action_promptPayment_to_alreadyPaid)
+ navigateToTransaction(payStatus.transactionId)
+ Snackbar.make(requireView(), R.string.payment_already_paid, LENGTH_LONG).show()
}
- is PayStatus.Error -> {
+ is PayStatus.Pending -> {
showLoading(false)
- ui.details.errorView.text = getString(R.string.payment_error, payStatus.error)
- ui.details.errorView.fadeIn()
+ paymentManager.resetPayStatus()
+ navigateToTransaction(payStatus.transactionId)
+ if (payStatus.error != null && model.devMode.value == true) {
+ showError(payStatus.error)
+ } else {
+ showError(getString(R.string.payment_pending))
+ }
}
is PayStatus.None -> {
// No payment active.
@@ -131,7 +153,7 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
}
}
- private fun showOrder(contractTerms: ContractTerms, amount:Amount, totalFees: Amount? = null) {
+ private fun showOrder(contractTerms: ContractTerms, amount: Amount, totalFees: Amount? = null) {
ui.details.orderView.text = contractTerms.summary
adapter.setItems(contractTerms.products)
ui.details.productsList.fadeIn()
@@ -153,4 +175,13 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
f.show(parentFragmentManager, "image")
}
+ private fun navigateToTransaction(id: String?) {
+ lifecycleScope.launch {
+ if (id != null && transactionManager.selectTransaction(id)) {
+ findNavController().navigate(R.id.action_promptPayment_to_nav_transactions_detail_payment)
+ } else {
+ findNavController().navigate(R.id.action_promptPayment_to_nav_main)
+ }
+ }
+ }
}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
new file mode 100644
index 0000000..beb37d9
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
@@ -0,0 +1,174 @@
+/*
+ * 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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+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.common.ContractMerchant
+import net.taler.common.CurrencySpecification
+import net.taler.common.Timestamp
+import net.taler.common.toAbsoluteTime
+import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.transactions.AmountType
+import net.taler.wallet.transactions.ErrorTransactionButton
+import net.taler.wallet.transactions.TransactionAction
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
+import net.taler.wallet.transactions.TransactionAmountComposable
+import net.taler.wallet.transactions.TransactionInfo
+import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionLinkComposable
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import net.taler.wallet.transactions.TransactionPayment
+import net.taler.wallet.transactions.TransactionState
+import net.taler.wallet.transactions.TransitionsComposable
+
+@Composable
+fun TransactionPaymentComposable(
+ t: TransactionPayment,
+ devMode: Boolean,
+ spec: CurrencySpecification?,
+ onFulfill: (url: String) -> Unit,
+ onTransition: (t: TransactionAction) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ val context = LocalContext.current
+ Text(
+ modifier = Modifier.padding(16.dp),
+ text = t.timestamp.ms.toAbsoluteTime(context).toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.transaction_order_total),
+ amount = t.amountRaw.withSpec(spec),
+ amountType = AmountType.Neutral,
+ )
+
+ if (t.amountEffective > t.amountRaw) {
+ val fee = t.amountEffective - t.amountRaw
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+ }
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.transaction_paid),
+ amount = t.amountEffective.withSpec(spec),
+ 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 ?: "")
+ }
+
+ TransitionsComposable(t, devMode, onTransition)
+ if (devMode && t.error != null) {
+ ErrorTransactionButton(error = t.error)
+ }
+ }
+}
+
+@Composable
+fun PurchaseDetails(
+ info: TransactionInfo,
+ onClick: () -> Unit,
+) {
+ Column(
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ // Summary and fulfillment message
+ val text = if (info.fulfillmentMessage == null) {
+ info.summary
+ } else {
+ "${info.summary}\n\n${info.fulfillmentMessage}"
+ }
+ if (info.fulfillmentUrl != null) {
+ TransactionLinkComposable(
+ label = stringResource(id = R.string.transaction_order),
+ info = text,
+ ) { onClick() }
+ } else {
+ TransactionInfoComposable(
+ label = stringResource(id = R.string.transaction_order),
+ info = text,
+ )
+ }
+ // Order ID
+ Text(
+ stringResource(id = R.string.transaction_order_id, info.orderId),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+}
+
+@Preview
+@Composable
+fun TransactionPaymentComposablePreview() {
+ val t = TransactionPayment(
+ transactionId = "transactionId",
+ timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
+ txState = TransactionState(Pending),
+ txActions = listOf(Retry, Suspend, Abort),
+ info = TransactionInfo(
+ orderId = "123",
+ merchant = ContractMerchant(name = "Taler"),
+ summary = "Some Product that was bought and can have quite a long label",
+ fulfillmentMessage = "This is some fulfillment message",
+ fulfillmentUrl = "https://bank.demo.taler.net/",
+ products = listOf(),
+ ),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.1337"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.23"),
+ error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED),
+ )
+ TalerSurface {
+ TransactionPaymentComposable(t = t, devMode = true, spec = null, onFulfill = {}) {}
+ }
+}