taler-android

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

commit 0222da65ab41283d52acd885d94ec42c67bec6b4
parent 37bd0bfe7f1a1931c700fc27bab095e4dda9c4bc
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu,  5 Jun 2025 17:47:25 +0200

[wallet] v1 contract support

Diffstat:
Mmerchant-lib/src/main/java/net/taler/merchantlib/Orders.kt | 2+-
Mmerchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt | 5+++--
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/PosConfig.kt | 4++--
Mmerchant-terminal/src/main/java/net/taler/merchantpos/order/Order.kt | 5++---
Mmerchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt | 3++-
Mmerchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt | 5++---
Mtaler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt | 16++++++++++++++--
Mtaler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt | 305+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Ataler-kotlin-android/src/main/java/net/taler/common/Order.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtaler-kotlin-android/src/main/java/net/taler/common/TalerUtils.kt | 3++-
Mwallet/src/main/java/net/taler/wallet/MainFragment.kt | 4++--
Mwallet/src/main/java/net/taler/wallet/MainViewModel.kt | 6+++---
Mwallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt | 30++++--------------------------
Mwallet/src/main/java/net/taler/wallet/compose/Banner.kt | 9+++++++--
Mwallet/src/main/java/net/taler/wallet/compose/BottomButtonBox.kt | 44+++++++++++++++++++++++++++++---------------
Mwallet/src/main/java/net/taler/wallet/compose/ExpandableCard.kt | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mwallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt | 4++--
Mwallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt | 3++-
Mwallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt | 10++++++++--
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt | 156++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mwallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mwallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt | 3++-
Awallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt | 951+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt | 7++++---
Awallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment2.kt | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt | 4++--
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt | 3++-
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt | 1+
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt | 5++++-
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/transactions/Transactions.kt | 4++--
Mwallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsFragment.kt | 6+++---
Mwallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt | 4++--
Mwallet/src/main/res/values/strings.xml | 21++++++++++++++++++++-
43 files changed, 1840 insertions(+), 224 deletions(-)

diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/Orders.kt b/merchant-lib/src/main/java/net/taler/merchantlib/Orders.kt @@ -26,7 +26,7 @@ import net.taler.common.RelativeTime @Serializable data class PostOrderRequest( @SerialName("order") - val contractTerms: ContractTerms, + val contractTerms: net.taler.common.Order, @SerialName("refund_delay") val refundDelay: RelativeTime? = null, @SerialName("create_token") diff --git a/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt b/merchant-lib/src/test/java/net/taler/merchantlib/MerchantApiTest.kt @@ -22,7 +22,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import net.taler.common.Amount import net.taler.common.ContractProduct -import net.taler.common.ContractTerms +import net.taler.common.Order +import net.taler.common.OrderProduct import net.taler.common.Timestamp import net.taler.merchantlib.MockHttpClient.giveJsonResponse import net.taler.merchantlib.MockHttpClient.httpClient @@ -63,7 +64,7 @@ class MerchantApiTest { price = Amount("TEST", 1, 0), quantity = 2 ) - val contractTerms = ContractTerms( + val contractTerms = Order( summary = "test", amount = Amount("TEST", 2, 1), fulfillmentUrl = "http://example.org", diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/PosConfig.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/PosConfig.kt @@ -21,7 +21,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import net.taler.common.Amount import net.taler.common.ContractProduct -import net.taler.common.Product +import net.taler.common.OrderProduct import net.taler.common.TalerUtils import net.taler.common.Tax import net.taler.merchantlib.MerchantConfig @@ -99,7 +99,7 @@ data class ConfigProduct( override val taxes: Set<Tax>? = null, val categories: List<Int>, val quantity: Int = 0 -) : Product() { +) : OrderProduct() { val totalPrice by lazy { price * quantity } fun toContractProduct() = ContractProduct( diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/Order.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/Order.kt @@ -17,7 +17,6 @@ package net.taler.merchantpos.order import net.taler.common.Amount -import net.taler.common.ContractTerms import net.taler.common.Timestamp import net.taler.common.now import net.taler.merchantpos.config.Category @@ -107,9 +106,9 @@ data class Order(val id: Int, val currency: String, val availableCategories: Map }.toMap() } - fun toContractTerms(): ContractTerms { + fun toContractTerms(): net.taler.common.Order { val deadline = Timestamp.fromMillis(now() + HOURS.toMillis(1)) - return ContractTerms( + return net.taler.common.Order( summary = summary, summaryI18n = summaryI18n, amount = total, diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt @@ -31,6 +31,7 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil.ItemCallback import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter +import net.taler.common.base64Bitmap import net.taler.merchantpos.R import net.taler.merchantpos.config.ConfigProduct import net.taler.merchantpos.order.OrderAdapter.OrderViewHolder @@ -91,7 +92,7 @@ internal class OrderAdapter : Adapter<OrderViewHolder>() { price.text = product.totalPrice.amountStr // base64 encoded image - val bitmap = product.imageBitmap + val bitmap = product.image?.base64Bitmap if (bitmap == null) { image.visibility = GONE } else { diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt @@ -16,9 +16,7 @@ package net.taler.merchantpos.order -import android.graphics.BitmapFactory.decodeByteArray import android.os.Bundle -import android.util.Base64 import android.view.LayoutInflater import android.view.View import android.view.View.GONE @@ -32,6 +30,7 @@ import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.ViewHolder +import net.taler.common.base64Bitmap import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.config.ConfigProduct @@ -115,7 +114,7 @@ private class ProductAdapter( price.text = product.price.amountStr // base64 encoded image - val bitmap = product.imageBitmap + val bitmap = product.image?.base64Bitmap if (bitmap == null) { image.visibility = GONE } else { diff --git a/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt b/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt @@ -25,6 +25,7 @@ import android.content.Context.CONNECTIVITY_SERVICE import android.content.Intent import android.content.Intent.ACTION_SEND import android.content.Intent.EXTRA_INITIAL_INTENTS +import android.graphics.BitmapFactory.decodeByteArray import android.content.Intent.EXTRA_STREAM import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.graphics.Bitmap @@ -43,6 +44,7 @@ import android.text.format.DateUtils.FORMAT_SHOW_YEAR import android.text.format.DateUtils.MINUTE_IN_MILLIS import android.text.format.DateUtils.formatDateTime import android.text.format.DateUtils.getRelativeTimeSpanString +import android.util.Base64 import android.util.Log import android.view.View import android.view.View.INVISIBLE @@ -259,4 +261,14 @@ suspend fun String.shareAsQrCode(context: Context, authority: String) { } catch(e: IOException) { Log.d("taler-kotlin-android", "Failed to generate or store PNG image") } -} -\ No newline at end of file +} + +private val REGEX_BASE64_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$") + +val String.base64Bitmap: Bitmap? + get() = REGEX_BASE64_IMAGE.matchEntire(this)?.let { match -> + match.groups[2]?.value?.let { group -> + val decodedString = Base64.decode(group, Base64.DEFAULT) + decodeByteArray(decodedString, 0, decodedString.size) + } + } +\ No newline at end of file diff --git a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt @@ -16,64 +16,181 @@ package net.taler.common -import android.graphics.Bitmap -import android.graphics.BitmapFactory.decodeByteArray -import android.os.Build -import android.util.Base64 +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import net.taler.common.TalerUtils.getLocalizedString - -val REGEX_PRODUCT_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$") +import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive @Serializable -data class ContractTerms( - val summary: String, - @SerialName("summary_i18n") - val summaryI18n: Map<String, String>? = null, - val amount: Amount, - @SerialName("fulfillment_url") - val fulfillmentUrl: String? = null, - @SerialName("fulfillment_message") - val fulfillmentMessage: String? = null, - @SerialName("fulfillment_message_i18n") - val fulfillmentMessageI18n: Map <String, String>? = null, - val products: List<ContractProduct>? = null, - @SerialName("wire_transfer_deadline") - val wireTransferDeadline: Timestamp? = null, - @SerialName("refund_deadline") - val refundDeadline: Timestamp? = null, - @SerialName("pay_deadline") - val payDeadline: Timestamp? = null -) +enum class ContractVersion(val version: Int) { + V0(0), + V1(1), +} -abstract class Product { - abstract val productId: String? - abstract val description: String - abstract val descriptionI18n: Map<String, String>? - abstract val price: Amount? - abstract val location: String? - abstract val image: String? - abstract val taxes: Set<Tax>? - val localizedDescription: String - get() = if (Build.VERSION.SDK_INT >= 26) { - getLocalizedString(descriptionI18n, description) - } else { - description - } +@OptIn(ExperimentalSerializationApi::class) +@Serializable(with = ContractTermsSerializer::class) +sealed class ContractTerms { + abstract val version: ContractVersion + abstract val summary: String + abstract val summaryI18n: Map<String, String>? + abstract val orderId: String + abstract val fulfillmentUrl: String? + abstract val fulfillmentMessage: String? + abstract val fulfillmentMessageI18n: Map <String, String>? + abstract val products: List<ContractProduct>? + abstract val refundDeadline: Timestamp? + abstract val payDeadline: Timestamp? + abstract val wireTransferDeadline: Timestamp? + abstract val merchantBaseUrl: String + abstract val merchant: Merchant + abstract val exchanges: List<Exchange> + abstract val deliveryLocation: Location? + abstract val deliveryDate: Timestamp? - val imageBitmap: Bitmap? - get() = image?.let { - REGEX_PRODUCT_IMAGE.matchEntire(it)?.let { match -> - match.groups[2]?.value?.let { group -> - val decodedString = Base64.decode(group, Base64.DEFAULT) - decodeByteArray(decodedString, 0, decodedString.size) - } - } - } + @Serializable + @JsonClassDiscriminator("version") + data class V0 ( + override val summary: String, + + @SerialName("summary_i18n") + override val summaryI18n: Map<String, String>? = null, + + @SerialName("order_id") + override val orderId: String, + + @SerialName("fulfillment_url") + override val fulfillmentUrl: String? = null, + + @SerialName("fulfillment_message") + override val fulfillmentMessage: String? = null, + + @SerialName("fulfillment_message_i18n") + override val fulfillmentMessageI18n: Map<String, String>? = null, + + override val products: List<ContractProduct>? = null, + + @SerialName("refund_deadline") + override val refundDeadline: Timestamp? = null, + + @SerialName("pay_deadline") + override val payDeadline: Timestamp? = null, + + @SerialName("wire_transfer_deadline") + override val wireTransferDeadline: Timestamp? = null, + + @SerialName("merchant_base_url") + override val merchantBaseUrl: String, + + override val merchant: Merchant, + + override val exchanges: List<Exchange> = listOf(), + + @SerialName("delivery_location") + override val deliveryLocation: Location? = null, + + @SerialName("delivery_date") + override val deliveryDate: Timestamp? = null, + + val amount: Amount, + + @SerialName("max_fee") + val maxFee: Amount, + ) : ContractTerms() { + override val version: ContractVersion = ContractVersion.V0 + } + + @Serializable + @JsonClassDiscriminator("version") + data class V1 ( + override val summary: String, + + @SerialName("summary_i18n") + override val summaryI18n: Map<String, String>? = null, + + @SerialName("order_id") + override val orderId: String, + + @SerialName("fulfillment_url") + override val fulfillmentUrl: String? = null, + + @SerialName("fulfillment_message") + override val fulfillmentMessage: String? = null, + + @SerialName("fulfillment_message_i18n") + override val fulfillmentMessageI18n: Map<String, String>? = null, + + override val products: List<ContractProduct>? = null, + + @SerialName("refund_deadline") + override val refundDeadline: Timestamp? = null, + + @SerialName("pay_deadline") + override val payDeadline: Timestamp? = null, + + @SerialName("wire_transfer_deadline") + override val wireTransferDeadline: Timestamp? = null, + + @SerialName("merchant_base_url") + override val merchantBaseUrl: String, + + override val merchant: Merchant, + + override val exchanges: List<Exchange> = listOf(), + + @SerialName("delivery_location") + override val deliveryLocation: Location? = null, + + @SerialName("delivery_date") + override val deliveryDate: Timestamp? = null, + + val choices: List<ContractChoice>, + + @SerialName("token_families") + val tokenFamilies: Map<String, ContractTokenFamily>, + ) : ContractTerms() { + override val version: ContractVersion = ContractVersion.V1 + + fun getTokenFamily(slug: String) = tokenFamilies[slug] + } } @Serializable +data class Merchant( + val name: String, + val email: String? = null, + val website: String? = null, + val logo: String? = null, + val address: Location? = null, + val jurisdiction: Location? = null +) + +@Serializable +data class Location( + val country: String? = null, + @SerialName("country_subdivision") + val countrySubdivision: String? = null, + val district: String? = null, + val town: String? = null, + @SerialName("town_location") + val townLocation: String? = null, + @SerialName("post_code") + val postCode: String? = null, + val street: String? = null, + @SerialName("building_name") + val buildingName: String? = null, + @SerialName("building_number") + val buildingNumber: String? = null, + @SerialName("address_lines") + val addressLines: List<String>? = null, +) + +@Serializable data class ContractProduct( @SerialName("product_id") override val productId: String? = null, @@ -86,19 +203,102 @@ data class ContractProduct( override val image: String? = null, override val taxes: Set<Tax>? = null, val quantity: Int = 1, -) : Product() { +) : OrderProduct() { val totalPrice: Amount? by lazy { price?.let { price * quantity } } } @Serializable +data class Exchange( + val url: String, +) + +@Serializable data class Tax( val name: String, val tax: Amount, ) @Serializable -data class ContractMerchant( - val name: String +data class ContractChoice( + val amount: Amount, + val inputs: List<ContractInput>, + val outputs: List<ContractOutput>, + @SerialName("max_fee") + val maxFee: Amount, +) + +@Serializable +enum class ContractInputType { + @SerialName("token") + Token, +} + +@Serializable +sealed class ContractInput { + abstract val type: ContractInputType + + @Serializable + @SerialName("token") + data class Token( + @SerialName("token_family_slug") + val tokenFamilySlug: String, + val count: Int = 1, + ): ContractInput() { + override val type: ContractInputType = ContractInputType.Token + } +} + +@Serializable +enum class ContractOutputType { + @SerialName("token") + Token, +} + +@Serializable +sealed class ContractOutput { + abstract val type: ContractOutputType + + @Serializable + @SerialName("token") + data class Token( + @SerialName("token_family_slug") + val tokenFamilySlug: String, + val count: Int = 1, + ): ContractOutput() { + override val type: ContractOutputType = ContractOutputType.Token + } +} + +@Serializable +data class ContractTokenFamily( + val name: String, + val description: String, + val descriptionI18n: Map<String, String>? = null, + val details: ContractTokenDetails, + val critical: Boolean, ) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("class") +sealed class ContractTokenDetails { + @Serializable + @SerialName("subscription") + data object Subscription: ContractTokenDetails() + + @Serializable + @SerialName("discount") + data object Discount: ContractTokenDetails() +} + +object ContractTermsSerializer : JsonContentPolymorphicSerializer<ContractTerms>(ContractTerms::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy<ContractTerms> { + return when(val type = element.jsonObject["version"]?.jsonPrimitive?.intOrNull) { + null, 0 -> ContractTerms.V0.serializer() + 1 -> ContractTerms.V1.serializer() + else -> error("unknown contract version $type") + } + } +} +\ No newline at end of file diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Order.kt b/taler-kotlin-android/src/main/java/net/taler/common/Order.kt @@ -0,0 +1,58 @@ +/* + * This file is part of GNU Taler + * (C) 2025 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.common + +import android.os.Build +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.taler.common.TalerUtils.getLocalizedString + +// TODO: order v1 support + +@Serializable +data class Order( + val summary: String, + @SerialName("summary_i18n") + val summaryI18n: Map<String, String>? = null, + val amount: Amount, + @SerialName("fulfillment_url") + val fulfillmentUrl: String? = null, + @SerialName("fulfillment_message") + val fulfillmentMessage: String? = null, + @SerialName("fulfillment_message_i18n") + val fulfillmentMessageI18n: Map <String, String>? = null, + val products: List<ContractProduct>? = null, + @SerialName("wire_transfer_deadline") + val wireTransferDeadline: Timestamp? = null, + @SerialName("refund_deadline") + val refundDeadline: Timestamp? = null, + @SerialName("pay_deadline") + val payDeadline: Timestamp? = null +) + +@Serializable +abstract class OrderProduct { + abstract val productId: String? + abstract val description: String + abstract val descriptionI18n: Map<String, String>? + abstract val price: Amount? + abstract val location: String? + abstract val image: String? + abstract val taxes: Set<Tax>? + val localizedDescription: String + get() = getLocalizedString(descriptionI18n, description) +} +\ No newline at end of file diff --git a/taler-kotlin-android/src/main/java/net/taler/common/TalerUtils.kt b/taler-kotlin-android/src/main/java/net/taler/common/TalerUtils.kt @@ -16,16 +16,17 @@ package net.taler.common +import android.os.Build import androidx.annotation.RequiresApi import androidx.core.os.LocaleListCompat import java.util.Locale object TalerUtils { - @RequiresApi(26) fun getLocalizedString(map: Map<String, String>?, default: String): String { // just return the default, if it is the only element if (map == null) return default + if (Build.VERSION.SDK_INT < 26) return default // create a priority list of language ranges from system locales val locales = LocaleListCompat.getDefault() val priorityList = ArrayList<Locale.LanguageRange>(locales.size()) diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt @@ -122,7 +122,7 @@ class MainFragment: Fragment() { val selectedScope by model.transactionManager.selectedScope.collectAsStateLifecycleAware() val txStateFilter by model.transactionManager.stateFilter.collectAsStateLifecycleAware() val txResult by remember(selectedScope, txStateFilter) { model.transactionManager.transactionsFlow(selectedScope, stateFilter = txStateFilter) }.collectAsStateLifecycleAware() - val selectedSpec = remember(selectedScope) { selectedScope?.let { model.balanceManager.getSpecForScopeInfo(it) } } + val selectedSpec = remember(selectedScope) { selectedScope?.let { model.exchangeManager.getSpecForScopeInfo(it) } } val actionButtonUsed by remember { model.settingsManager.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true) Scaffold( @@ -251,7 +251,7 @@ class MainFragment: Fragment() { // unfinished transactions (dialog) TransactionState(TransactionMajorState.Dialog) -> when (tx) { is TransactionPayment -> { - model.paymentManager.preparePay(tx) { + model.paymentManager.preparePay(tx.transactionId) { findNavController().navigate(R.id.action_global_promptPayment) } } diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -111,11 +111,11 @@ class MainViewModel( private val api = WalletBackendApi(app, walletConfig, this, this) val networkManager = NetworkManager(app.applicationContext) - val paymentManager = PaymentManager(api, viewModelScope) + val exchangeManager: ExchangeManager = ExchangeManager(api, viewModelScope) + val balanceManager = BalanceManager(api, viewModelScope, exchangeManager) + val paymentManager = PaymentManager(api, viewModelScope, exchangeManager) val transactionManager: TransactionManager = TransactionManager(api, viewModelScope) val refundManager = RefundManager(api, viewModelScope) - val balanceManager = BalanceManager(api, viewModelScope) - val exchangeManager: ExchangeManager = ExchangeManager(api, viewModelScope) val withdrawManager = WithdrawManager(api, viewModelScope, exchangeManager, transactionManager) val peerManager: PeerManager = PeerManager(api, exchangeManager, viewModelScope) val settingsManager: SettingsManager = SettingsManager(app.applicationContext, api, viewModelScope, balanceManager) diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt @@ -32,6 +32,7 @@ import net.taler.wallet.TAG import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.exchanges.ExchangeItem +import net.taler.wallet.exchanges.ExchangeManager import org.json.JSONObject @Serializable @@ -60,6 +61,7 @@ sealed class BalanceState { class BalanceManager( private val api: WalletBackendApi, private val scope: CoroutineScope, + private val exchangeManager: ExchangeManager, ) { private val mBalances = MutableLiveData<List<BalanceItem>>(emptyList()) val balances: LiveData<List<BalanceItem>> = mBalances @@ -67,8 +69,6 @@ class BalanceManager( private val mState = MutableLiveData<BalanceState>(BalanceState.None) val state: LiveData<BalanceState> = mState.distinctUntilChanged() - private val currencySpecs: MutableMap<ScopeInfo, CurrencySpecification?> = mutableMapOf() - @UiThread fun loadBalances() { if (mState.value == BalanceState.None) { @@ -86,14 +86,12 @@ class BalanceManager( scope.launch { // Fetch missing currency specs for all balances it.balances.forEach { balance -> - if (!currencySpecs.containsKey(balance.scopeInfo)) { - currencySpecs[balance.scopeInfo] = getCurrencySpecification(balance.scopeInfo) - } + exchangeManager.getCurrencySpecification(balance.scopeInfo) } mState.postValue( BalanceState.Success(it.balances.map { balance -> - val spec = currencySpecs[balance.scopeInfo] + val spec = exchangeManager.getCurrencySpecification(balance.scopeInfo) balance.copy( available = balance.available.withSpec(spec), pendingIncoming = balance.pendingIncoming.withSpec(spec), @@ -121,26 +119,6 @@ class BalanceManager( return spec } - @Deprecated("Please find spec via scopeInfo instead", ReplaceWith("getSpecForScopeInfo")) - fun getSpecForCurrency(currency: String): CurrencySpecification? { - val state = mState.value - if (state !is BalanceState.Success) return null - - return state.balances.find { it.currency == currency }?.available?.spec - } - - fun getSpecForCurrency(currency: String, scopes: List<ScopeInfo>) = - scopes.find { it.currency == currency }?.let { scope -> - getSpecForScopeInfo(scope) - } - - fun getSpecForScopeInfo(scopeInfo: ScopeInfo): CurrencySpecification? { - val state = mState.value - if (state !is BalanceState.Success) return null - - return state.balances.find { it.scopeInfo == scopeInfo }?.available?.spec - } - @UiThread fun getCurrencies() = balances.value?.map { balanceItem -> balanceItem.currency diff --git a/wallet/src/main/java/net/taler/wallet/compose/Banner.kt b/wallet/src/main/java/net/taler/wallet/compose/Banner.kt @@ -26,7 +26,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.ShapeDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -34,6 +36,7 @@ import androidx.compose.ui.unit.dp fun Banner( modifier: Modifier = Modifier, colors: CardColors = CardDefaults.cardColors(), + shape: Shape = ShapeDefaults.ExtraSmall, content: @Composable () -> Unit, ) { Card( @@ -41,11 +44,13 @@ fun Banner( .safeHorizontalPadding() .fillMaxWidth(), colors = colors, - shape = ShapeDefaults.ExtraSmall, + shape = shape, ) { Box(Modifier .padding(10.dp) - .fillMaxWidth()) { + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { ProvideTextStyle(MaterialTheme.typography.labelLarge.copy( textAlign = TextAlign.Center, )) { diff --git a/wallet/src/main/java/net/taler/wallet/compose/BottomButtonBox.kt b/wallet/src/main/java/net/taler/wallet/compose/BottomButtonBox.kt @@ -19,6 +19,7 @@ package net.taler.wallet.compose import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -36,27 +37,37 @@ import androidx.compose.ui.unit.dp @Composable fun BottomButtonBox( modifier: Modifier = Modifier, + heading: (@Composable ColumnScope.() -> Unit)? = null, leading: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = null, ) { - Row( - modifier = modifier - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, + Column(modifier = modifier + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(12.dp), + horizontalAlignment = Alignment.End, ) { - leading?.let { - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterStart, - ) { it() } + if (heading != null) { + Column( + modifier = Modifier.padding(bottom = 6.dp), + horizontalAlignment = Alignment.End, + content = heading, + ) } - trailing?.let { - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterEnd, - ) { it() } + Row(verticalAlignment = Alignment.Bottom) { + leading?.let { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { it() } + } + + trailing?.let { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterEnd, + ) { it() } + } } } } @@ -70,6 +81,9 @@ fun BottomButtonBoxPreview() { BottomButtonBox( modifier = Modifier.fillMaxWidth(), + heading = { + Text("Something, something") + }, leading = { Button(onClick = {}) { Text("Back") diff --git a/wallet/src/main/java/net/taler/wallet/compose/ExpandableCard.kt b/wallet/src/main/java/net/taler/wallet/compose/ExpandableCard.kt @@ -18,6 +18,7 @@ package net.taler.wallet.compose import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -27,9 +28,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -46,6 +50,7 @@ import androidx.compose.ui.unit.dp fun ExpandableCard( modifier: Modifier = Modifier, expanded: Boolean = false, + section: Boolean = false, // card or section? setExpanded: (expanded: Boolean) -> Unit, header: @Composable RowScope.() -> Unit, content: @Composable ColumnScope.() -> Unit, @@ -55,10 +60,7 @@ fun ExpandableCard( label = "Rotation state of expand icon button", ) - OutlinedCard( - modifier = modifier.padding(8.dp), - onClick = { setExpanded(!expanded) } - ) { + val body = @Composable { Column( modifier = Modifier .fillMaxWidth() @@ -67,11 +69,20 @@ fun ExpandableCard( Row( modifier = Modifier .fillMaxWidth() + .clickable { setExpanded(!expanded) } .padding(start = 16.dp, top = 4.dp, bottom = 4.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - header() + ProvideTextStyle( + if (section) { + MaterialTheme.typography.titleLarge + } else { + MaterialTheme.typography.titleMedium + }, + ) { + header() + } IconButton( modifier = Modifier.rotate(rotationState), @@ -89,16 +100,51 @@ fun ExpandableCard( } } } + + if (section) { + Column { + body() + HorizontalDivider() + } + } else { + OutlinedCard( + modifier = modifier.padding(8.dp), + onClick = { setExpanded(!expanded) } + ) { + body() + } + } +} + +@Composable +fun ExpandableSection( + modifier: Modifier = Modifier, + expanded: Boolean = false, + setExpanded: (expanded: Boolean) -> Unit, + header: @Composable RowScope.() -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + ExpandableCard( + modifier = modifier, + expanded = expanded, + section = true, + setExpanded = setExpanded, + header = header, + content = content, + ) } @Preview @Composable -fun ExpandableCardPreview() { +fun ExpandableCardPreview( + section: Boolean = false, +) { TalerSurface { var expanded by remember { mutableStateOf(true) } ExpandableCard( expanded = expanded, setExpanded = { expanded = it }, + section = section, header = { Text("Swiss QR") }, content = { QrCodeUriComposable( @@ -109,4 +155,10 @@ fun ExpandableCardPreview() { } ) } +} + +@Preview +@Composable +fun ExpandableSectionPreview() { + ExpandableCardPreview(true) } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt @@ -45,7 +45,7 @@ class DepositFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val depositManager get() = model.depositManager private val accountManager get() = model.accountManager - private val balanceManager get() = model.balanceManager + private val exchangeManager get() = model.exchangeManager override fun onCreateView( inflater: LayoutInflater, @@ -101,7 +101,7 @@ class DepositFragment : Fragment() { is DepositState.AccountSelected -> { DepositAmountComposable( state = s, - getCurrencySpec = balanceManager::getSpecForCurrency, + getCurrencySpec = exchangeManager::getSpecForCurrency, checkDeposit = { a -> depositManager.checkDepositFees(s.account.paytoUri, a) }, diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt @@ -61,6 +61,7 @@ class PayToUriFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val depositManager get() = model.depositManager private val balanceManager get() = model.balanceManager + private val exchangeManager get() = model.exchangeManager override fun onCreateView( inflater: LayoutInflater, @@ -92,7 +93,7 @@ class PayToUriFragment : Fragment() { findNavController().navigate( R.id.action_nav_payto_uri_to_nav_deposit, bundle) }, - getCurrencySpec = balanceManager::getSpecForCurrency, + getCurrencySpec = exchangeManager::getSpecForCurrency, ) else Text( text = stringResource(id = R.string.uri_invalid), color = MaterialTheme.colorScheme.error, diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -25,14 +25,20 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import net.taler.common.CurrencySpecification import net.taler.common.Event import net.taler.common.toEvent import net.taler.wallet.TAG +import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.balances.GetCurrencySpecificationResponse import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.withdraw.TosResponse +import org.json.JSONObject @Serializable data class ExchangeListResponse( @@ -64,6 +70,8 @@ class ExchangeManager( var withdrawalExchange: ExchangeItem? = null + private val currencySpecs: MutableMap<ScopeInfo, CurrencySpecification?> = mutableMapOf() + private fun list(): LiveData<List<ExchangeItem>> { mProgress.value = true scope.launch { @@ -75,6 +83,14 @@ class ExchangeManager( Log.d(TAG, "Exchange list: ${it.exchanges}") mProgress.value = false mExchanges.value = it.exchanges + scope.launch { + // load and cache all currency specs + it.exchanges.forEach { exchange -> + if (exchange.scopeInfo != null) { + getCurrencySpecification(exchange.scopeInfo) + } + } + } } } return mExchanges @@ -266,4 +282,38 @@ class ExchangeManager( } } + suspend fun getCurrencySpecification(scopeInfo: ScopeInfo): CurrencySpecification? { + if (currencySpecs.containsKey(scopeInfo)) { + return currencySpecs[scopeInfo] + } + + var spec: CurrencySpecification? = null + api.request("getCurrencySpecification", GetCurrencySpecificationResponse.serializer()) { + val json = BackendManager.json.encodeToString(scopeInfo) + Log.d(TAG, "ExchangeManager: $json") + put("scope", JSONObject(json)) + }.onSuccess { + currencySpecs[scopeInfo] = it.currencySpecification + spec = it.currencySpecification + }.onError { + Log.e(TAG, "Error getting currency spec for scope $scopeInfo: $it") + } + return spec + } + + @Deprecated("Please find spec via scopeInfo instead", ReplaceWith("getSpecForScopeInfo")) + fun getSpecForCurrency(currency: String): CurrencySpecification? { + return currencySpecs.keys.firstOrNull { + it.currency == currency + }?.let { currencySpecs[it] } + } + + fun getSpecForCurrency(currency: String, scopes: List<ScopeInfo>) = + scopes.find { it.currency == currency }?.let { scope -> + runBlocking { getCurrencySpecification(scope) } + } + + fun getSpecForScopeInfo(scopeInfo: ScopeInfo): CurrencySpecification? { + return runBlocking { getCurrencySpecification(scopeInfo) } + } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp import net.taler.common.Amount import net.taler.common.ContractTerms import net.taler.common.CurrencySpecification +import net.taler.common.Merchant import net.taler.wallet.AmountResult import net.taler.wallet.R import net.taler.wallet.compose.LoadingScreen @@ -89,6 +90,7 @@ fun PayTemplateComposable( } is PayStatus.Prepared -> {} // handled in fragment, will redirect is PayStatus.Success -> {} // handled by other UI flow, no need for content here + is PayStatus.Choices -> {} // only applies to regular payments } } @@ -139,10 +141,14 @@ fun PayTemplateInsufficientBalancePreview() { PayTemplateComposable( payStatus = PayStatus.InsufficientBalance( "txn:3409F039F09", - ContractTerms( + ContractTerms.V0( "test", amount = Amount.zero("TESTKUDOS"), - products = emptyList() + products = emptyList(), + orderId = "xxxxx", + merchantBaseUrl = "https://backend.test.taler.net/", + merchant = Merchant(name = "Test Backend"), + maxFee = Amount.zero("TESTKUDOS") ), Amount.zero("TESTKUDOS"), PaymentInsufficientBalanceDetails( diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt @@ -60,7 +60,7 @@ class PayTemplateFragment : Fragment() { onCreateAmount = model::createAmount, onSubmit = this@PayTemplateFragment::createOrder, onError = { this@PayTemplateFragment.showError(it) }, - getCurrencySpec = model.balanceManager::getSpecForCurrency, + getCurrencySpec = model.exchangeManager::getSpecForCurrency, ) } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -22,33 +22,40 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.JsonClassDiscriminator import net.taler.common.Amount +import net.taler.common.ContractInput +import net.taler.common.ContractOutput import net.taler.common.ContractTerms import net.taler.wallet.TAG import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.balances.BalanceManager +import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.exchanges.ExchangeManager 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 net.taler.wallet.transactions.TransactionPayment import org.json.JSONObject +import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail sealed class PayStatus { data object None : PayStatus() data object Loading : PayStatus() data class Prepared( + val transactionId: String, val contractTerms: ContractTerms, + ) : PayStatus() + + data class Choices( val transactionId: String, - val amountRaw: Amount, - val amountEffective: Amount, + val contractTerms: ContractTerms, + val choices: List<PayChoiceDetails>, + val defaultChoiceIndex: Int? = null, ) : PayStatus() data class Checked( @@ -73,44 +80,28 @@ sealed class PayStatus { ) : PayStatus() data class Success( val transactionId: String, - val currency: String, + val automaticExecution: Boolean, ) : PayStatus() } +data class PayChoiceDetails( + val choiceIndex: Int, + val amountRaw: Amount, + val inputs: List<ContractInput>, + val outputs: List<ContractOutput>, + val details: ChoiceSelectionDetail, +) + @Serializable data class CheckPayTemplateResponse( val templateDetails: WalletTemplateDetails, val supportedCurrencies: List<String>, ) -@Serializable -data class GetChoicesForPaymentResponse( - val choices: List<ChoiceSelectionDetail>, - val contractData: ContractTerms, -) { - @Serializable - @OptIn(ExperimentalSerializationApi::class) - @JsonClassDiscriminator("status") - sealed class ChoiceSelectionDetail { - @Serializable - @SerialName("payment-possible") - data class PaymentPossible( - val amountRaw: Amount, - val amountEffective: Amount, - ) : ChoiceSelectionDetail() - - @Serializable - @SerialName("insufficient-balance") - data class InsufficientBalance( - val amountRaw: Amount, - val balanceDetails: PaymentInsufficientBalanceDetails? = null, - ) : ChoiceSelectionDetail() - } -} - class PaymentManager( private val api: WalletBackendApi, private val scope: CoroutineScope, + private val exchangeManager: ExchangeManager, ) { private val mPayStatus = MutableLiveData<PayStatus>(PayStatus.None) @@ -124,62 +115,86 @@ class PaymentManager( }.onError { handleError("preparePayForUri", it) }.onSuccess { response -> - mPayStatus.value = when (response) { - is PaymentPossibleResponse -> response.toPayStatusPrepared() - is InsufficientBalanceResponse -> InsufficientBalance( - transactionId = response.transactionId, - contractTerms = response.contractTerms, - amountRaw = response.amountRaw, - balanceDetails = response.balanceDetails, - ) - is AlreadyConfirmedResponse -> AlreadyPaid( - transactionId = response.transactionId, - ) + if (response is AlreadyConfirmedResponse) { + mPayStatus.value = AlreadyPaid(response.transactionId) + return@onSuccess } + + val transactionId = when (response) { + is PaymentPossibleResponse -> response.transactionId + is InsufficientBalanceResponse -> response.transactionId + is PreparePayResponse.ChoiceSelection -> response.transactionId + else -> return@onSuccess + } + + preparePay(transactionId) {} } } @UiThread fun preparePay( - tx: TransactionPayment, + transactionId: String, onSuccess: () -> Unit, ) = scope.launch { api.request("getChoicesForPayment", GetChoicesForPaymentResponse.serializer()) { - put("transactionId", tx.transactionId) + put("transactionId", transactionId) }.onSuccess { res -> - // TODO: this is a terrible v0-only hack! - if (res.choices.size == 1) { - when (val choice = res.choices[0]) { - is GetChoicesForPaymentResponse.ChoiceSelectionDetail.PaymentPossible -> { - mPayStatus.value = PayStatus.Prepared( - transactionId = tx.transactionId, - amountRaw = choice.amountRaw, - amountEffective = choice.amountEffective, - contractTerms = res.contractData, - ) - onSuccess() - } + if (res.automaticExecution == true && res.defaultChoiceIndex != null) { + confirmPay(transactionId, res.defaultChoiceIndex, automaticExecution = true) + return@onSuccess + } + + mPayStatus.value = PayStatus.Choices( + transactionId = transactionId, + contractTerms = res.contractData, + defaultChoiceIndex = res.defaultChoiceIndex, + choices = res.choices.map { choice -> + val spec = exchangeManager.getSpecForCurrency( + choice.amountRaw.currency, + res.contractData.exchanges.map { + ScopeInfo.Exchange(choice.amountRaw.currency, it.url) + }, + ) ?: exchangeManager.getSpecForCurrency(choice.amountRaw.currency) - is GetChoicesForPaymentResponse.ChoiceSelectionDetail.InsufficientBalance -> { - if (choice.balanceDetails != null) { - mPayStatus.value = InsufficientBalance( - transactionId = tx.transactionId, - amountRaw = choice.amountRaw, - contractTerms = res.contractData, - balanceDetails = choice.balanceDetails, + when (choice) { + is ChoiceSelectionDetail.PaymentPossible -> { + choice.copy( + amountRaw = choice.amountRaw.withSpec(spec), + amountEffective = choice.amountEffective.withSpec(spec), ) - onSuccess() + } + + is ChoiceSelectionDetail.InsufficientBalance -> { + choice.copy(amountRaw = choice.amountRaw.withSpec(spec)) } } - } - } + }.mapIndexed { i, choice -> + PayChoiceDetails( + choiceIndex = i, + amountRaw = choice.amountRaw, + inputs = (res.contractData as? ContractTerms.V1) + ?.choices?.get(i)?.inputs ?: listOf(), + outputs = (res.contractData as? ContractTerms.V1) + ?.choices?.get(i)?.outputs ?: listOf(), + details = choice, + ) + }, + ) + + onSuccess() }.onError { error -> handleError("getChoicesForPayment", error) } } - fun confirmPay(transactionId: String, currency: String) = scope.launch { + fun confirmPay( + transactionId: String, + choiceIndex: Int? = null, + automaticExecution: Boolean = false, + ) = scope.launch { + mPayStatus.postValue(PayStatus.Loading) api.request("confirmPay", ConfirmPayResult.serializer()) { + choiceIndex?.let { put("choiceIndex", it) } put("transactionId", transactionId) }.onError { handleError("confirmPay", it) @@ -187,7 +202,7 @@ class PaymentManager( mPayStatus.postValue(when (response) { is ConfirmPayResult.Done -> PayStatus.Success( transactionId = response.transactionId, - currency = currency, + automaticExecution = automaticExecution, ) is ConfirmPayResult.Pending -> PayStatus.Pending( transactionId = response.transactionId, @@ -231,6 +246,9 @@ class PaymentManager( is AlreadyConfirmedResponse -> AlreadyPaid( transactionId = response.transactionId, ) + + // only applies to regular payments + is PreparePayResponse.ChoiceSelection -> return@onSuccess } } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt @@ -43,15 +43,11 @@ sealed class PreparePayResponse { @SerialName("payment-possible") data class PaymentPossibleResponse( val transactionId: String, - val amountRaw: Amount, - val amountEffective: Amount, val contractTerms: ContractTerms, ) : PreparePayResponse() { fun toPayStatusPrepared() = PayStatus.Prepared( contractTerms = contractTerms, transactionId = transactionId, - amountRaw = amountRaw, - amountEffective = amountEffective, ) } @@ -76,6 +72,45 @@ sealed class PreparePayResponse { val amountEffective: Amount? = null, val contractTerms: ContractTerms, ) : PreparePayResponse() + + @Serializable + @SerialName("choice-selection") + data class ChoiceSelection( + val transactionId: String, + val contractTerms: ContractTerms, + ) : PreparePayResponse() +} + +@Serializable +data class GetChoicesForPaymentResponse( + val choices: List<ChoiceSelectionDetail>, + val contractData: ContractTerms, + val defaultChoiceIndex: Int? = null, + val automaticExecution: Boolean? = null, +) { + @Serializable + @OptIn(ExperimentalSerializationApi::class) + @JsonClassDiscriminator("status") + sealed class ChoiceSelectionDetail { + abstract val amountRaw: Amount + abstract val tokenDetails: PaymentTokenAvailabilityDetails? + + @Serializable + @SerialName("payment-possible") + data class PaymentPossible( + override val amountRaw: Amount, + val amountEffective: Amount, + override val tokenDetails: PaymentTokenAvailabilityDetails? = null, + ) : ChoiceSelectionDetail() + + @Serializable + @SerialName("insufficient-balance") + data class InsufficientBalance( + override val amountRaw: Amount, + val balanceDetails: PaymentInsufficientBalanceDetails? = null, + override val tokenDetails: PaymentTokenAvailabilityDetails? = null, + ) : ChoiceSelectionDetail() + } } @Serializable @@ -231,6 +266,62 @@ data class PaymentInsufficientBalanceDetails( } @Serializable +data class PaymentTokenAvailabilityDetails( + /** + * Number of tokens requested by the merchant. + */ + val tokensRequested: Int, + + /** + * Number of tokens for which the merchant is unexpected. + * + * Can be used to pay (i.e. with forced selection), + * but a warning should be displayed to the user. + */ + val tokensAvailable: Int, + + /** + * Number of tokens for which the merchant is untrusted. + * + * Cannot be used to pay, so an error should be displayed. + */ + val tokensUnexpected: Int, + + /** + * Number of tokens with a malformed domain. + * + * Cannot be used to pay, so an error should be displayed. + */ + val tokensUntrusted: Int, + + // {[slug: String]: PerTokenFamily} + val perTokenFamily: Map<String, PerTokenFamily>, +) { + @Serializable + data class PerTokenFamily( + val causeHint: TokenAvailabilityHint? = null, + val requested: Int, + val available: Int, + val unexpected: Int, + val untrusted: Int, + ) +} + +@Serializable +enum class TokenAvailabilityHint { + Unknown, + + @SerialName("wallet-tokens-available-insufficient") + WalletTokensAvailableInsufficient, + + @SerialName("merchant-unexpected") + MerchantUnexpected, + + @SerialName("merchant-untrusted") + MerchantUntrusted, +} + +@Serializable sealed class ConfirmPayResult { @Serializable @SerialName("done") diff --git a/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt @@ -29,6 +29,7 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import net.taler.common.ContractProduct +import net.taler.common.base64Bitmap import net.taler.wallet.R import net.taler.wallet.payment.ProductAdapter.ProductViewHolder @@ -76,7 +77,7 @@ internal class ProductAdapter(private val listener: ProductImageClickListener) : quantity.text = product.quantity.toString() // base64 encoded image - val bitmap = product.imageBitmap + val bitmap = product.image?.base64Bitmap if (bitmap == null) { image.visibility = GONE } else { diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt @@ -0,0 +1,950 @@ +/* + * This file is part of GNU Taler + * (C) 2025 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.graphics.Bitmap +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Percent +import androidx.compose.material.icons.filled.Store +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import net.taler.common.Amount +import net.taler.common.ContractChoice +import net.taler.common.ContractInput +import net.taler.common.ContractOutput +import net.taler.common.ContractProduct +import net.taler.common.ContractTerms +import net.taler.common.ContractTokenDetails +import net.taler.common.ContractTokenFamily +import net.taler.common.Exchange +import net.taler.common.Merchant +import net.taler.common.TalerUtils +import net.taler.common.base64Bitmap +import net.taler.wallet.R +import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.BottomButtonBox +import net.taler.wallet.compose.ExpandableSection +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.InsufficientBalance +import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.PaymentPossible +import net.taler.wallet.payment.TokenAvailabilityHint.MerchantUnexpected +import net.taler.wallet.payment.TokenAvailabilityHint.MerchantUntrusted +import net.taler.wallet.payment.TokenAvailabilityHint.WalletTokensAvailableInsufficient +import net.taler.wallet.systemBarsPaddingBottom + +// TODO: error handling +// TODO: info sheets + +@Composable +fun PromptPaymentComposable( + status: PayStatus.Choices, + onConfirm: (choiceIndex: Int?) -> Unit, + onCancel: () -> Unit, + onClickImage: (Bitmap) -> Unit, +) { + val contractTerms = status.contractTerms + var showCancelDialog by rememberSaveable { mutableStateOf(false) } + + OrderCancelDialog( + showCancelDialog, + onDismiss = { showCancelDialog = false }, + onConfirm = { onCancel() }, + ) + + Column( + Modifier + .fillMaxSize() + .imePadding(), + ) { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .fillMaxWidth(), + ) { + MerchantSection(contractTerms, onClickImage) + + // REVIEW ORDER SECTION + var orderExpanded by rememberSaveable { + mutableStateOf(contractTerms is ContractTerms.V0) + } + + ExpandableSection( + expanded = orderExpanded, + setExpanded = { orderExpanded = it }, + header = { Text(stringResource(R.string.payment_section_review)) }, + ) { + OrderSection(contractTerms, onClickImage) + } + + // PAYMENT OPTIONS SECTION + if (contractTerms is ContractTerms.V1) { + var choicesExpanded by rememberSaveable { mutableStateOf(true) } + var selectedIndex by rememberSaveable { mutableIntStateOf(status.defaultChoiceIndex ?: 0) } + ExpandableSection( + expanded = choicesExpanded, + setExpanded = { choicesExpanded = it }, + header = { Text(stringResource(R.string.payment_section_choices)) }, + ) { + ChoicesSection( + status, + contractTerms.tokenFamilies, + selectedIndex, + contractTerms.merchantBaseUrl, + onSelect = { index -> selectedIndex = index }, + onConfirm = { index -> onConfirm(index) }, + ) + } + } + } + + BottomButtonBox(Modifier.fillMaxWidth(), + heading = if (contractTerms is ContractTerms.V0) { -> + val choice = status.choices.firstOrNull() + ?: error("no v0 choice") + + Text( + stringResource( + R.string.payment_amount_total, + choice.details.amountRaw, + ) + ) + + if (choice.details is PaymentPossible) { + PaymentFeeLabel( + modifier = Modifier.padding(bottom = 3.dp), + amountRaw = choice.details.amountRaw, + amountEffective = choice.details.amountEffective, + ) + } else if (choice.details is InsufficientBalance) { + Text( + modifier = Modifier.padding(bottom = 3.dp), + text = stringResource(R.string.payment_balance_insufficient), + color = MaterialTheme.colorScheme.error, + ) + } + } else null, + leading = { + OutlinedButton( + modifier = Modifier.systemBarsPaddingBottom(), + enabled = true, + onClick = { showCancelDialog = true }, + ) { + Text( + stringResource(R.string.payment_button_cancel), + color = MaterialTheme.colorScheme.error + ) + } + }, + trailing = { + if (contractTerms is ContractTerms.V0) { + val choice = status.choices.firstOrNull() + ?: error("no v0 choice") + Button( + modifier = Modifier.systemBarsPaddingBottom(), + enabled = choice.details is PaymentPossible, + onClick = { onConfirm(null) }, + ) { + if (choice.details is PaymentPossible) { + Text(stringResource( + R.string.payment_button_confirm_amount, + choice.details.amountEffective, + )) + } else { + Text(stringResource( + R.string.payment_button_confirm_amount, + choice.details.amountRaw, + )) + } + } + } + }, + ) + } +} + +@Composable +fun MerchantSection( + contractTerms: ContractTerms, + onClickImage: (Bitmap) -> Unit, +) { + val merchant = contractTerms.merchant + + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalAlignment = CenterHorizontally, + ) { + // MERCHANT LOGO + val logo = remember(merchant.logo) { + merchant.logo?.base64Bitmap + } + + Box( + Modifier + .size(60.dp) + .background( + shape = CircleShape, + color = if (logo != null) { + Color.White + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ) + .clip(CircleShape) + .clickable { if (logo != null) onClickImage(logo) }, + contentAlignment = Alignment.Center, + ) { + if (logo != null) { + Image( + logo.asImageBitmap(), + modifier = Modifier.fillMaxSize(), + contentDescription = null, + ) + } else { + Icon( + Icons.Default.Store, + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + contentDescription = null, + ) + } + } + + // MERCHANT NAME + Text( + merchant.name, + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.titleLarge, + ) + + // MERCHANT INFO BUTTON + // TextButton(onClick = {}) { + // Icon( + // Icons.Outlined.Info, + // contentDescription = null, + // modifier = Modifier.size(ButtonDefaults.IconSize), + // ) + // Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + // Text("Merchant info") + // } + } +} + +@Composable +fun MerchantInfoSheet() {} + +@Composable +fun OrderCancelDialog( + show: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + if (show) AlertDialog( + title = { Text(stringResource(R.string.payment_cancel_dialog_title)) }, + text = { Text(stringResource(R.string.payment_cancel_dialog_message)) }, + onDismissRequest = { onDismiss() }, + confirmButton = { + TextButton(onClick = { + onDismiss() + }) { + Text(stringResource(R.string.button_back)) + } + }, + dismissButton = { + TextButton(onClick = { + onConfirm() + }) { + Text(stringResource(R.string.payment_cancel_dialog_title)) + } + }, + ) +} + +@Composable +fun OrderSection( + contractTerms: ContractTerms, + onClickImage: (Bitmap) -> Unit, +) { + Column(horizontalAlignment = CenterHorizontally) { + // ORDER SUMMARY + Text( + contractTerms.summary, + modifier = Modifier + .padding(top = 3.dp, bottom = 12.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + // PRODUCT LIST + // TODO: LazyColumn would be better, but can't be nested + val products = contractTerms.products + products?.forEach { product -> + ProductItem(product, onClickImage) + } + + // ORDER INFO BUTTON + // TextButton( + // onClick = {}, + // modifier = Modifier.padding(bottom = 9.dp), + // ) { + // Icon( + // Icons.Outlined.Info, + // contentDescription = null, + // modifier = Modifier.size(ButtonDefaults.IconSize), + // ) + // Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + // Text("More details") + // } + } +} + +@Composable +fun ProductItem( + product: ContractProduct, + onClickImage: (Bitmap) -> Unit, +) { + val image = remember(product.image) { + product.image?.base64Bitmap + } + + ListItem( + leadingContent = { + // IMAGE + if (image != null) { + Image( + image.asImageBitmap(), + modifier = Modifier + .size(30.dp) + .clickable { onClickImage(image) }, + contentDescription = null, + ) + } + }, + + headlineContent = { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End, + ) { + // NAME + Text( + product.localizedDescription, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyLarge, + ) + + // PRICE + product.price?.let { price -> + Text( + if (product.quantity > 1) { + stringResource( + R.string.payment_product_price_quantity, + product.quantity.toString(), + price.toString(), + ) + } else { + price.toString() + }, + style = MaterialTheme.typography.bodyMedium.copy( + color = ListItemDefaults.colors().supportingTextColor, + ), + ) + } + } + }, + ) +} + +@Composable +fun ChoicesSection( + status: PayStatus.Choices, + tokenFamilies: Map<String, ContractTokenFamily>, + selectedIndex: Int, + merchantBaseUrl: String, + onSelect: (choiceIndex: Int) -> Unit, + onConfirm: (choiceIndex: Int) -> Unit, +) { + // TODO: CURRENCIES + + // CHOICES + // TODO: LazyColumn would be better, but can't be nested + status.choices.sortedWith( + compareByDescending<PayChoiceDetails> { + it.choiceIndex == status.defaultChoiceIndex + }.thenByDescending { + it.details is PaymentPossible + }.thenByDescending { + it.amountRaw + } + ).forEach { choice -> + PaymentChoice( + choice, + tokenFamilies, + merchantBaseUrl, + selectedIndex == choice.choiceIndex, + onSelect = { onSelect(choice.choiceIndex) }, + onConfirm = { onConfirm(choice.choiceIndex) }, + ) + } +} + +@Composable +fun PaymentChoice( + choice: PayChoiceDetails, + tokenFamilies: Map<String, ContractTokenFamily>, + merchantBaseUrl: String, + selected: Boolean, + onSelect: () -> Unit, + onConfirm: () -> Unit, +) { + OutlinedCard( + modifier = Modifier + .padding( + horizontal = 9.dp, + vertical = 6.dp, + ).fillMaxWidth() + .animateContentSize() + .clickable { onSelect() }, + border = if (selected) { + BorderStroke(2.5.dp, MaterialTheme.colorScheme.primary) + } else { + CardDefaults.outlinedCardBorder() + }, + ) { + Column(Modifier) { + Column(Modifier.padding(15.dp)) { + // CHOICE AMOUNT + Text( + text = if (choice.amountRaw.isZero()) { + stringResource(R.string.payment_button_confirm_tokens) + } else { + choice.amountRaw.toString() + }, + style = MaterialTheme.typography.titleLarge, + ) + + if (choice.details is PaymentPossible) { + PaymentFeeLabel( + amountRaw = choice.details.amountRaw, + amountEffective = choice.details.amountEffective, + ) + } + + // INPUTS + if (choice.inputs.isNotEmpty()) { + Column { + Text( + stringResource(R.string.payment_choice_inputs), + modifier = Modifier.padding(top = 9.dp, bottom = 3.dp), + style = MaterialTheme.typography.labelLarge, + ) + + choice.inputs.forEach { input -> + PaymentInput(input, merchantBaseUrl, tokenFamilies, choice.details.tokenDetails) + } + } + } + + // OUTPUTS + if (choice.outputs.isNotEmpty()) { + Column { + Text( + stringResource(R.string.payment_choice_outputs), + modifier = Modifier.padding(top = 9.dp, bottom = 3.dp), + style = MaterialTheme.typography.labelLarge, + ) + + choice.outputs.forEach { output -> + PaymentOutput(output, tokenFamilies, merchantBaseUrl) + } + } + } + + // CONFIRM BUTTON + if (selected) Button( + modifier = Modifier + .padding(top = 9.dp) + .fillMaxWidth(), + onClick = onConfirm, + enabled = choice.details is PaymentPossible, + ) { + val tokenDetails = choice.details.tokenDetails + Text( + if (choice.details is PaymentPossible) { + if (choice.details.amountEffective.isZero()) { + stringResource(R.string.payment_button_confirm_tokens) + } else { + stringResource( + R.string.payment_button_confirm_amount, + choice.details.amountEffective + ) + } + } else if (tokenDetails != null && + tokenDetails.tokensAvailable + < tokenDetails.tokensRequested) { + stringResource( + R.string.payment_tokens_insufficient, + ) + } else { + stringResource( + R.string.payment_balance_insufficient, + ) + }, + ) + } + } + } + } +} + +@Composable +fun PaymentInput( + input: ContractInput, + merchantBaseUrl: String, + tokenFamilies: Map<String, ContractTokenFamily>, + tokenAvailabilityDetails: PaymentTokenAvailabilityDetails?, +) { + when (input) { + is ContractInput.Token -> { + // TODO: calculate from outside? + // TODO: better error handling + val family = tokenFamilies[input.tokenFamilySlug] + ?: error("no token family ${input.tokenFamilySlug}") + + val availability = tokenAvailabilityDetails?.perTokenFamily + ?.get(input.tokenFamilySlug) + + TokenCard( + name = family.name, + description = TalerUtils.getLocalizedString( + family.descriptionI18n, + family.description, + ), + count = input.count, + details = family.details, + merchantBaseUrl = merchantBaseUrl, + availabilityHint = availability?.causeHint, + ) + } + } +} + +@Composable +fun PaymentOutput( + output: ContractOutput, + tokenFamilies: Map<String, ContractTokenFamily>, + merchantBaseUrl: String, +) { + when (output) { + is ContractOutput.Token -> { + // TODO: calculate from outside? + // TODO: better error handling + val family = tokenFamilies[output.tokenFamilySlug] + ?: error("no token family for ${output.tokenFamilySlug}") + + TokenCard( + name = family.name, + description = TalerUtils.getLocalizedString( + family.descriptionI18n, + family.description, + ), + count = output.count, + details = family.details, + merchantBaseUrl = merchantBaseUrl, + ) + } + } +} + +@Composable +fun TokenCard( + name: String, + description: String, + count: Int, + details: ContractTokenDetails, + merchantBaseUrl: String, + availabilityHint: TokenAvailabilityHint? = null, +) { + Card ( + modifier = Modifier + .padding(vertical = 5.dp, + ).fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier.padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + when (details) { + is ContractTokenDetails.Discount -> Icon( + Icons.Default.Percent, + contentDescription = stringResource(R.string.payment_token_discount), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + is ContractTokenDetails.Subscription -> Icon( + Icons.Default.Autorenew, + contentDescription = stringResource(R.string.payment_token_subscription), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Column(Modifier.weight(1f)) { + Text( + if (count > 1) { + stringResource(R.string.payment_product_price_quantity, count, name) + } else { + name + }, + modifier = Modifier.padding(bottom = 4.dp), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + ) + + Text( + description, + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (availabilityHint != null) { + TokenWarningTooltip(merchantBaseUrl, availabilityHint) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TokenWarningTooltip( + merchantBaseUrl: String, + availabilityHint: TokenAvailabilityHint, +) { + val icon = when(availabilityHint) { + WalletTokensAvailableInsufficient -> Icons.Default.Error + MerchantUntrusted -> Icons.Default.Error + MerchantUnexpected -> Icons.Default.Warning + else -> return + } + + val tint = when(availabilityHint) { + WalletTokensAvailableInsufficient -> MaterialTheme.colorScheme.error + MerchantUntrusted -> MaterialTheme.colorScheme.error + MerchantUnexpected -> MaterialTheme.colorScheme.onSurfaceVariant + else -> return + } + + val text = when(availabilityHint) { + WalletTokensAvailableInsufficient -> stringResource(R.string.payment_tokens_insufficient) + MerchantUntrusted -> stringResource(R.string.payment_tokens_untrusted, cleanExchange(merchantBaseUrl)) + MerchantUnexpected -> stringResource(R.string.payment_tokens_unexpected, cleanExchange(merchantBaseUrl)) + else -> return + } + + val tooltipState = rememberTooltipState() + val coroutineScope = rememberCoroutineScope() + + TooltipBox( + modifier = Modifier, + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(text) } }, + state = tooltipState, + ) { + IconButton(onClick = { + coroutineScope.launch { + tooltipState.show() + } + }) { + Icon( + icon, + tint = tint, + contentDescription = when(availabilityHint) { + WalletTokensAvailableInsufficient -> stringResource(R.string.error) + MerchantUntrusted -> stringResource(R.string.error) + MerchantUnexpected -> stringResource(R.string.warning) + else -> return@IconButton + }, + ) + } + } +} + +@Composable +fun PaymentFeeLabel( + modifier: Modifier = Modifier, + amountRaw: Amount, + amountEffective: Amount, +) { + if (amountEffective > amountRaw) { + val fee = amountEffective - amountRaw + Text( + modifier = modifier, + text = stringResource( + R.string.payment_fee, + fee.toString(showSymbol = false), + ), + style = MaterialTheme.typography.labelLarge.copy( + color = MaterialTheme.colorScheme.error, + ), + ) + } +} + +private val contractTermsV0 = ContractTerms.V0( + merchant = Merchant( + name = "Demo Shop", + logo = "data:image/png;base64,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", + ), + summary = "order summary", + orderId = "102910291029", + merchantBaseUrl = "https://backend.demo.taler.net/", + amount = Amount.fromJSONString("KUDOS:1"), + maxFee = Amount.fromJSONString("KUDOS:0"), + products = listOf( + ContractProduct( + description = "something", + price = Amount.fromJSONString("KUDOS:1"), + image = "data:image/png;base64,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", + quantity = 2, + ), + + ContractProduct( + description = "something", + price = Amount.fromJSONString("KUDOS:1"), + image = "data:image/png;base64,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", + quantity = 1, + ), + ), + exchanges = listOf( + Exchange(url = "https://exchange.demo.taler.net/"), + Exchange(url = "https://exchange.test.taler.net/"), + ), +) + +private val contractTermsV1 = ContractTerms.V1( + merchant = contractTermsV0.merchant, + summary = contractTermsV0.summary, + orderId = contractTermsV0.orderId, + merchantBaseUrl = contractTermsV0.merchantBaseUrl, + products = contractTermsV0.products, + exchanges = contractTermsV0.exchanges, + + choices = listOf( + ContractChoice( + amount = Amount.fromJSONString("KUDOS:10"), + maxFee = Amount.fromJSONString("KUDOS:0"), + inputs = listOf( + ContractInput.Token(tokenFamilySlug = "half-tax", count = 2), + ContractInput.Token(tokenFamilySlug = "movie-pass"), + ), + outputs = listOf( + ContractOutput.Token(tokenFamilySlug = "movie-pass"), + ), + ), + + ContractChoice( + amount = Amount.fromJSONString("KUDOS:200"), + maxFee = Amount.fromJSONString("KUDOS:0"), + inputs = listOf( + ContractInput.Token(tokenFamilySlug = "movie-pass"), + ), + outputs = listOf( + ContractOutput.Token(tokenFamilySlug = "movie-pass"), + ), + ), + + ContractChoice( + amount = Amount.fromJSONString("KUDOS:0"), + maxFee = Amount.fromJSONString("KUDOS:0"), + inputs = listOf( + ContractInput.Token(tokenFamilySlug = "movie-pass"), + ), + outputs = listOf( + ContractOutput.Token(tokenFamilySlug = "movie-pass"), + ), + ), + ), + + tokenFamilies = mapOf( + "half-tax" to ContractTokenFamily( + name = "half-tax", + description = "50% discount", + details = ContractTokenDetails.Discount, + critical = true, + ), + + "movie-pass" to ContractTokenFamily( + name = "movie-pass", + description = "Monthly movie pass", + details = ContractTokenDetails.Subscription, + critical = true, + ), + ), +) + +@Preview +@Composable +fun PromptPaymentV0Preview() { + TalerSurface { + PromptPaymentComposable(PayStatus.Choices( + transactionId = "txn:payment:2309203920", + contractTerms = contractTermsV0, + choices = listOf( + PayChoiceDetails( + choiceIndex = 0, + amountRaw = Amount.fromJSONString("KUDOS:10"), + inputs = listOf(), + outputs = listOf(), + details = PaymentPossible( + amountRaw = Amount.fromJSONString("KUDOS:10"), + amountEffective = Amount.fromJSONString("KUDOS:10.2"), + ), + ) + ) + ), {}, {}, {}) + } +} + +@Preview +@Composable +fun PromptPaymentV1Preview() { + TalerSurface { + PromptPaymentComposable(PayStatus.Choices( + transactionId = "txn:payment:2309203920", + contractTerms = contractTermsV1, + defaultChoiceIndex = 2, + choices = listOf( + PayChoiceDetails( + choiceIndex = 0, + amountRaw = contractTermsV1.choices[0].amount, + inputs = contractTermsV1.choices[0].inputs, + outputs = contractTermsV1.choices[0].outputs, + details = PaymentPossible( + amountRaw = contractTermsV1.choices[0].amount, + amountEffective = contractTermsV1.choices[0].amount + .plus(Amount.fromJSONString("KUDOS:0.1")), + tokenDetails = PaymentTokenAvailabilityDetails( + tokensRequested = 3, + tokensAvailable = 2, + tokensUntrusted = 1, + tokensUnexpected = 1, + perTokenFamily = mapOf( + "half-tax" to PaymentTokenAvailabilityDetails.PerTokenFamily( + causeHint = MerchantUntrusted, + requested = 2, + available = 1, + untrusted = 1, + unexpected = 0 + ), + "movie-pass" to PaymentTokenAvailabilityDetails.PerTokenFamily( + causeHint = MerchantUnexpected, + requested = 1, + available = 1, + untrusted = 0, + unexpected = 1, + ), + ), + ), + ), + ), + PayChoiceDetails( + choiceIndex = 1, + amountRaw = contractTermsV1.choices[1].amount, + inputs = contractTermsV1.choices[1].inputs, + outputs = contractTermsV1.choices[1].outputs, + details = InsufficientBalance( + amountRaw = contractTermsV1.choices[1].amount, + ), + ), + PayChoiceDetails( + choiceIndex = 2, + amountRaw = contractTermsV1.choices[2].amount, + inputs = contractTermsV1.choices[2].inputs, + outputs = contractTermsV1.choices[2].outputs, + details = PaymentPossible( + amountRaw = contractTermsV1.choices[2].amount, + amountEffective = contractTermsV1.choices[2].amount, + ), + ), + ) + ), {}, {}, {}) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt @@ -22,12 +22,10 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.View.GONE -import android.view.View.VISIBLE import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -52,7 +50,8 @@ import net.taler.wallet.showError /** * Show a payment and ask the user to accept/decline. */ -class PromptPaymentFragment : Fragment(), ProductImageClickListener { +/* +class PromptPaymentFragment2 : Fragment(), ProductImageClickListener { private val model: MainViewModel by activityViewModels() private val paymentManager by lazy { model.paymentManager } @@ -237,3 +236,4 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { } } } +*/ +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment2.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment2.kt @@ -0,0 +1,151 @@ +/* + * This file is part of GNU Taler + * (C) 2025 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.graphics.Bitmap +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import net.taler.common.showError +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.TAG +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.showError + +// TODO: + +class PromptPaymentFragment: Fragment(), ProductImageClickListener { + private val model: MainViewModel by activityViewModels() + private val paymentManager by lazy { model.paymentManager } + private val transactionManager by lazy { model.transactionManager } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val payStatus by paymentManager.payStatus.observeAsState(PayStatus.None) + when(val status = payStatus) { + is PayStatus.None, + is PayStatus.Loading, + is PayStatus.Prepared -> LoadingScreen() + is PayStatus.Checked -> {} // does not apply, only used for templates + is PayStatus.Choices -> { + PromptPaymentComposable(status, + onConfirm = { index -> + paymentManager.confirmPay(status.transactionId, index) + }, + onCancel = { + transactionManager.abortTransaction( + status.transactionId, + onSuccess = { + Snackbar.make( + requireView(), + getString(R.string.payment_aborted), + LENGTH_LONG + ).show() + findNavController().popBackStack() + }, + onError = { error -> + Log.e(TAG, "Error abortTransaction $error") + if (model.devMode.value == false) { + showError(error.userFacingMsg) + } else { + showError(error) + } + } + ) + }, + onClickImage = { bitmap -> + onImageClick(bitmap) + } + ) + } + + else -> {} + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + paymentManager.payStatus.observe(viewLifecycleOwner) { status -> + when (status) { + is PayStatus.Success -> { + paymentManager.resetPayStatus() + navigateToTransaction(status.transactionId) + if (status.automaticExecution) { + Snackbar.make(requireView(), R.string.payment_automatic_execution, LENGTH_LONG).show() + } + } + + is PayStatus.AlreadyPaid -> { + paymentManager.resetPayStatus() + navigateToTransaction(status.transactionId) + Snackbar.make(requireView(), R.string.payment_already_paid, LENGTH_LONG).show() + } + + is PayStatus.Pending -> { + paymentManager.resetPayStatus() + navigateToTransaction(status.transactionId) + if (status.error != null) { + if (model.devMode.value == true) { + showError(status.error) + } else { + showError(status.error.userFacingMsg) + } + } + } + + else -> {} + } + } + } + + override fun onImageClick(image: Bitmap) { + val f = ProductImageFragment.new(image) + 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 @@ -31,8 +31,8 @@ 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.Merchant import net.taler.common.Timestamp import net.taler.common.toAbsoluteTime import net.taler.wallet.BottomInsetsSpacer @@ -166,7 +166,7 @@ fun TransactionPaymentComposablePreview() { txActions = listOf(Retry, Suspend, Abort), info = TransactionInfo( orderId = "123", - merchant = ContractMerchant(name = "Taler"), + merchant = Merchant(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/", diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -61,7 +61,7 @@ class OutgoingPullFragment : Fragment() { onTosAccept = this@OutgoingPullFragment::onTosAccept, defaultScope = remember { selectedScope }, scopes = balanceManager.getScopes(), - getCurrencySpec = balanceManager::getSpecForScopeInfo, + getCurrencySpec = exchangeManager::getSpecForScopeInfo, checkPeerPullCredit = { amount, loading -> transactionManager.selectScope(amount.scope) peerManager.checkPeerPullCredit(amount.amount, diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt @@ -43,6 +43,7 @@ class OutgoingPushFragment : Fragment() { private val peerManager get() = model.peerManager private val transactionManager get() = model.transactionManager private val balanceManager get() = model.balanceManager + private val exchangeManager get() = model.exchangeManager // hacky way to change back action until we have navigation for compose private val backPressedCallback = object : OnBackPressedCallback(false) { @@ -69,7 +70,7 @@ class OutgoingPushFragment : Fragment() { state = state, defaultScope = selectedScope, scopes = balanceManager.getScopes(), - getCurrencySpec = balanceManager::getSpecForScopeInfo, + getCurrencySpec = exchangeManager::getSpecForScopeInfo, getFees = { peerManager.checkPeerPushFees(it.amount, restrictScope = it.scope) }, onSend = this@OutgoingPushFragment::onSend, onClose = { diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt @@ -41,7 +41,7 @@ class TransactionDepositFragment : TransactionDetailFragment() { if (tx is TransactionDeposit) TransactionDepositComposable( t = tx, devMode = devMode, - spec = balanceManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), actionListener = this@TransactionDepositFragment, ) { onTransitionButtonClicked(tx, it) diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt @@ -48,6 +48,7 @@ abstract class TransactionDetailFragment : Fragment(), ActionListener { private val model: MainViewModel by activityViewModels() protected val transactionManager by lazy { model.transactionManager } protected val balanceManager by lazy { model.balanceManager } + protected val exchangeManager by lazy { model.exchangeManager } protected val withdrawManager by lazy { model.withdrawManager } protected val devMode get() = model.devMode.value == true diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt @@ -71,7 +71,7 @@ class TransactionLossFragment: TransactionDetailFragment() { TalerSurface { (t as? TransactionDenomLoss)?.let { tx -> val spec = remember(tx.amountRaw.currency, tx.scopes) { - balanceManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes) + exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes) } TransitionLossComposable(tx, devMode, spec) { onTransitionButtonClicked(tx, it) diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt @@ -39,7 +39,7 @@ class TransactionPaymentFragment : TransactionDetailFragment() { val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() (t as? TransactionPayment)?.let { tx -> TransactionPaymentComposable(tx, devMode, - balanceManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), onFulfill = { url -> launchInAppBrowser(requireContext(), url) }, diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt @@ -80,7 +80,7 @@ class TransactionPeerFragment : TransactionDetailFragment(), ActionListener { t?.let { tx -> TransactionPeerComposable( tx, devMode, - balanceManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), this@TransactionPeerFragment, ) { onTransitionButtonClicked(tx, it) diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt @@ -65,7 +65,7 @@ class TransactionRefreshFragment : TransactionDetailFragment() { val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() (t as? TransactionRefresh)?.let { tx -> TransactionRefreshComposable(tx, devMode, - balanceManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), ) { onTransitionButtonClicked(tx, it) } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt @@ -38,7 +38,7 @@ class TransactionRefundFragment : TransactionDetailFragment() { val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() (t as? TransactionRefund)?.let { tx -> TransactionRefundComposable(tx, devMode, - balanceManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes) + exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes) ) { onTransitionButtonClicked(tx, it) } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt @@ -115,7 +115,10 @@ fun TransactionStateComposable( else -> return } - Banner(colors = CardDefaults.cardColors(containerColor = cardColor)) { + Banner( + modifier = Modifier.padding(horizontal = 9.dp), + colors = CardDefaults.cardColors(containerColor = cardColor), + ) { Text( modifier = Modifier .fillMaxWidth(), diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt @@ -40,7 +40,7 @@ class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListene TransactionWithdrawalComposable( t = tx, devMode = devMode, - spec = balanceManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), + spec = exchangeManager.getSpecForCurrency(tx.amountRaw.currency, tx.scopes), actionListener = this@TransactionWithdrawalFragment, ) { onTransitionButtonClicked(tx, it) diff --git a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt @@ -37,7 +37,6 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonElement import net.taler.common.Amount import net.taler.common.Bech32 -import net.taler.common.ContractMerchant import net.taler.common.ContractProduct import net.taler.common.ContractTerms import net.taler.common.Timestamp @@ -46,6 +45,7 @@ import net.taler.wallet.TAG import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo import net.taler.common.CurrencySpecification +import net.taler.common.Merchant import net.taler.common.RelativeTime import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.refund.RefundPaymentInfo @@ -382,7 +382,7 @@ class TransactionPayment( @Serializable class TransactionInfo( val orderId: String, - val merchant: ContractMerchant, + val merchant: Merchant, val summary: String, @SerialName("summary_i18n") val summaryI18n: Map<String, String>? = null, diff --git a/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsFragment.kt b/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsFragment.kt @@ -52,7 +52,7 @@ class WireTransferDetailsFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val withdrawManager by lazy { model.withdrawManager } private val transactionManager by lazy { model.transactionManager } - private val balanceManager by lazy { model.balanceManager } + private val exchangeManager by lazy { model.exchangeManager } private var navigating: Boolean = false @@ -103,9 +103,9 @@ class WireTransferDetailsFragment : Fragment() { getQrCodes = { withdrawManager.getQrCodesForPayto(it.withdrawalAccount.paytoUri) }, spec = selectedTx?.amountRaw?.currency?.let { selectedTx?.scopes?.let { selectedScopes -> - balanceManager.getSpecForCurrency(it, selectedScopes) + exchangeManager.getSpecForCurrency(it, selectedScopes) } ?: run { - balanceManager.getSpecForCurrency(it) + exchangeManager.getSpecForCurrency(it) } }, bankAppClick = { onBankAppClick(it) }, diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -131,9 +131,9 @@ class PromptWithdrawFragment: Fragment() { val currencySpec = remember(exchange?.scopeInfo) { exchange?.scopeInfo?.let { scopeInfo -> - balanceManager.getSpecForScopeInfo(scopeInfo) + exchangeManager.getSpecForScopeInfo(scopeInfo) } ?: status.currency?.let { - balanceManager.getSpecForCurrency(it) + exchangeManager.getSpecForCurrency(it) } } diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="edit">Edit</string> <string name="enter_uri">Enter taler:// URI</string> <string name="enter_uri_label">Enter URI</string> + <string name="error">Error</string> <string name="import_db">Import</string> <string name="loading">Loading</string> <string name="menu">Menu</string> @@ -74,6 +75,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="reset">Reset</string> <string name="share_payment">Share payment link</string> <string name="uri_invalid">Not a valid Taler URI</string> + <string name="warning">Warning</string> <!-- Biometric lock --> <string name="biometric_auth_error">Authentication error: %1$s</string> @@ -198,7 +200,9 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="payment_aborted">Aborted</string> <string name="payment_aborting">Aborting</string> <string name="payment_already_paid">You\'ve already paid for this purchase.</string> - <string name="payment_balance_insufficient">Balance insufficient!</string> + <string name="payment_amount_total">Total: %1$s</string> + <string name="payment_automatic_execution">This payment was automatically confirmed</string> + <string name="payment_balance_insufficient">Insufficient balance</string> <string name="payment_balance_insufficient_hint_age_restricted">Purchase not possible due to age restriction</string> <string name="payment_balance_insufficient_hint_exchange_missing_global_fees">Provider is missing the global fee configuration, this likely means it is misconfigured</string> <string name="payment_balance_insufficient_hint_fees_not_covered">Not enough funds to pay the provider fees not covered by the merchant</string> @@ -208,8 +212,12 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="payment_balance_insufficient_max">Balance insufficient! Maximum is %1$s</string> <string name="payment_button_cancel">Cancel</string> <string name="payment_button_confirm">Confirm payment</string> + <string name="payment_button_confirm_amount">Pay %1$s</string> + <string name="payment_button_confirm_tokens">Pay with tokens</string> <string name="payment_cancel_dialog_message">Are you sure you want to cancel this payment? You won\'t be able to complete it later. Alternatively, you can simply exit this screen and come back later.</string> <string name="payment_cancel_dialog_title">Cancel payment</string> + <string name="payment_choice_inputs">Spend now</string> + <string name="payment_choice_outputs">You receive with purchase</string> <string name="payment_confirmation_code">Confirmation code</string> <string name="payment_create_order">Create order</string> <string name="payment_error">Error: %s</string> @@ -219,10 +227,21 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="payment_label_order_summary">Purchase</string> <string name="payment_pay_template_title">Customize your order</string> <string name="payment_pending">Payment not completed, it will be retried</string> + <!-- <quantity> × <price> --> + <string name="payment_product_price_quantity">%1$s × %2$s</string> <string name="payment_prompt_title">Review payment</string> <string name="payment_repurchase">Repurchase</string> + <string name="payment_section_choices">Select payment option</string> + <string name="payment_section_review">Review order</string> <!-- including <amount> <tax name> --> <string name="payment_tax">incl. %1$s %2$s</string> + <!-- <quantity> × <token name> --> + <string name="payment_token_name_quantity">%1$d × %2$s</string> + <string name="payment_token_discount">Discount</string> + <string name="payment_token_subscription">Subscription</string> + <string name="payment_tokens_insufficient">Insufficient tokens</string> + <string name="payment_tokens_unexpected">Unexpected merchant %1$s, your token could be stolen.</string> + <string name="payment_tokens_untrusted">Merchant %1$s is untrusted, cannot spend this token.</string> <string name="payment_template_error">Error creating order</string> <string name="payment_title">Payment</string>