summaryrefslogtreecommitdiff
path: root/wallet/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'wallet/src/main/java')
-rw-r--r--wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt273
-rw-r--r--wallet/src/main/java/net/taler/wallet/MainActivity.kt194
-rw-r--r--wallet/src/main/java/net/taler/wallet/MainFragment.kt33
-rw-r--r--wallet/src/main/java/net/taler/wallet/MainViewModel.kt221
-rw-r--r--wallet/src/main/java/net/taler/wallet/NetworkManager.kt64
-rw-r--r--wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt70
-rw-r--r--wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt77
-rw-r--r--wallet/src/main/java/net/taler/wallet/UriInputFragment.kt7
-rw-r--r--wallet/src/main/java/net/taler/wallet/Utils.kt45
-rw-r--r--wallet/src/main/java/net/taler/wallet/WalletApp.kt (renamed from wallet/src/main/java/net/taler/wallet/balances/BalanceResponse.kt)17
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt63
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt95
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt84
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/RequestManager.kt44
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt5
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt201
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/WalletBackendService.kt254
-rw-r--r--wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt82
-rw-r--r--wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt53
-rw-r--r--wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt137
-rw-r--r--wallet/src/main/java/net/taler/wallet/balances/Balances.kt57
-rw-r--r--wallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt46
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt228
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt (renamed from wallet/src/main/java/net/taler/wallet/settings/BackupSettingsFragment.kt)27
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt70
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt42
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt46
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt32
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/Utils.kt6
-rw-r--r--wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt22
-rw-r--r--wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt41
-rw-r--r--wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt4
-rw-r--r--wallet/src/main/java/net/taler/wallet/deposit/MakeBitcoinDepositComposable.kt41
-rw-r--r--wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt137
-rw-r--r--wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt85
-rw-r--r--wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt68
-rw-r--r--wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt163
-rw-r--r--wallet/src/main/java/net/taler/wallet/events/ObservabilityEvent.kt66
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/AddExchangeDialogFragment.kt3
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt28
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/ExchangeFeesFragment.kt4
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt75
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt118
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt38
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeDialogFragment.kt111
-rw-r--r--wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeFragment.kt48
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt49
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt171
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt126
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt104
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt221
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt107
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt19
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt26
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt48
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt174
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt128
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt87
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt10
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt10
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt19
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt280
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt58
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt141
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPullResultComposable.kt149
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt218
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt69
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPushIntroComposable.kt123
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingPushResultComposable.kt149
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt34
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt112
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt79
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt45
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt47
-rw-r--r--wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt110
-rw-r--r--wallet/src/main/java/net/taler/wallet/pending/PendingOperationsFragment.kt187
-rw-r--r--wallet/src/main/java/net/taler/wallet/pending/PendingOperationsManager.kt57
-rw-r--r--wallet/src/main/java/net/taler/wallet/refund/RefundManager.kt17
-rw-r--r--wallet/src/main/java/net/taler/wallet/refund/RefundPaymentInfo.kt39
-rw-r--r--wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt123
-rw-r--r--wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt86
-rw-r--r--wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt107
-rw-r--r--wallet/src/main/java/net/taler/wallet/tip/AlreadyAcceptedFragment.kt50
-rw-r--r--wallet/src/main/java/net/taler/wallet/tip/PromptTipFragment.kt158
-rw-r--r--wallet/src/main/java/net/taler/wallet/tip/TipManager.kt100
-rw-r--r--wallet/src/main/java/net/taler/wallet/tip/TipResponses.kt59
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/ActionButtonComposable.kt116
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/DeleteTransactionComposable.kt55
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/ErrorTransactionComposable.kt116
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt129
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt11
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt182
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt64
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionLinkComposable.kt86
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt163
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt174
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt46
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt44
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt105
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt46
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt99
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionTipFragment.kt119
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt120
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt339
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt70
-rw-r--r--wallet/src/main/java/net/taler/wallet/transactions/TransitionsComposable.kt113
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt111
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt157
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt245
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawFragment.kt16
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt69
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenBitcoin.kt171
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenIBAN.kt161
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt326
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferBitcoin.kt112
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferIBAN.kt93
-rw-r--r--wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferTaler.kt93
117 files changed, 7606 insertions, 3666 deletions
diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt
new file mode 100644
index 0000000..58adeee
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt
@@ -0,0 +1,273 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet
+
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast.LENGTH_LONG
+import androidx.compose.ui.platform.ComposeView
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import net.taler.common.isOnline
+import net.taler.common.showError
+import net.taler.wallet.compose.LoadingScreen
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.refund.RefundStatus
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.Locale
+
+class HandleUriFragment: Fragment() {
+ private val model: MainViewModel by activityViewModels()
+
+ lateinit var uri: String
+ lateinit var from: String
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ uri = arguments?.getString("uri") ?: error("no uri passed")
+ from = arguments?.getString("from") ?: error("no from passed")
+
+ return ComposeView(requireContext()).apply {
+ setContent {
+ TalerSurface {
+ LoadingScreen()
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val uri = Uri.parse(uri)
+ if (uri.fragment != null && !requireContext().isOnline()) {
+ connectToWifi(requireContext(), uri.fragment!!)
+ }
+
+ // TODO: fix this bad async programming, make it only async when needed.
+ getTalerAction(uri, 3, MutableLiveData<String>()).observe(viewLifecycleOwner) { u ->
+ Log.v(TAG, "found action $u")
+
+ if (u.startsWith("payto://", ignoreCase = true)) {
+ Log.v(TAG, "navigating with paytoUri!")
+ val bundle = bundleOf("uri" to u)
+ findNavController().navigate(R.id.action_handleUri_to_nav_payto_uri, bundle)
+ return@observe
+ }
+
+ val normalizedURL = u.lowercase(Locale.ROOT)
+ var ext = false
+ val action = normalizedURL.substring(
+ if (normalizedURL.startsWith("taler://", ignoreCase = true)) {
+ "taler://".length
+ } else if (normalizedURL.startsWith("ext+taler://", ignoreCase = true)) {
+ ext = true
+ "ext+taler://".length
+ } else if (normalizedURL.startsWith("taler+http://", ignoreCase = true) &&
+ model.devMode.value == true
+ ) {
+ "taler+http://".length
+ } else {
+ normalizedURL.length
+ }
+ )
+
+ // Remove ext+ scheme prefix if present
+ val u2 = if (ext) {
+ "taler://" + u.substring("ext+taler://".length)
+ } else u
+
+ when {
+ action.startsWith("pay/", ignoreCase = true) -> {
+ Log.v(TAG, "navigating!")
+ findNavController().navigate(R.id.action_handleUri_to_promptPayment)
+ model.paymentManager.preparePay(u2)
+ }
+ action.startsWith("withdraw/", ignoreCase = true) -> {
+ Log.v(TAG, "navigating!")
+ // there's more than one entry point, so use global action
+ findNavController().navigate(R.id.action_handleUri_to_promptWithdraw)
+ model.withdrawManager.getWithdrawalDetails(u2)
+ }
+
+ action.startsWith("withdraw-exchange/", ignoreCase = true) -> {
+ prepareManualWithdrawal(u2)
+ }
+
+ action.startsWith("refund/", ignoreCase = true) -> {
+ model.showProgressBar.value = true
+ model.refundManager.refund(u2).observe(viewLifecycleOwner, Observer(::onRefundResponse))
+ }
+ action.startsWith("pay-pull/", ignoreCase = true) -> {
+ findNavController().navigate(R.id.action_handleUri_to_promptPullPayment)
+ model.peerManager.preparePeerPullDebit(u2)
+ }
+ action.startsWith("pay-push/", ignoreCase = true) -> {
+ findNavController().navigate(R.id.action_handleUri_to_promptPushPayment)
+ model.peerManager.preparePeerPushCredit(u2)
+ }
+ action.startsWith("pay-template/", ignoreCase = true) -> {
+ val bundle = bundleOf("uri" to u2)
+ findNavController().navigate(R.id.action_handleUri_to_promptPayTemplate, bundle)
+ }
+ action.startsWith("dev-experiment/", ignoreCase = true) -> {
+ model.applyDevExperiment(u2) { error ->
+ showError(error)
+ }
+ findNavController().navigate(R.id.nav_main)
+ }
+ else -> {
+ showError(R.string.error_unsupported_uri, "From: $from\nURI: $u2")
+ findNavController().popBackStack()
+ }
+ }
+ }
+ }
+
+ private fun getTalerAction(
+ uri: Uri,
+ maxRedirects: Int,
+ actionFound: MutableLiveData<String>,
+ ): MutableLiveData<String> {
+ val scheme = uri.scheme ?: return actionFound
+
+ if (scheme == "http" || scheme == "https") {
+ model.viewModelScope.launch(Dispatchers.IO) {
+ val conn = URL(uri.toString()).openConnection() as HttpURLConnection
+ Log.v(TAG, "prepare query: $uri")
+ conn.setRequestProperty("Accept", "text/html")
+ conn.connectTimeout = 5000
+ conn.requestMethod = "HEAD"
+ try {
+ conn.connect()
+ } catch (e: IOException) {
+ Log.e(TAG, "Error connecting to $uri ", e)
+ showError(R.string.error_broken_uri, "$uri")
+ return@launch
+ }
+ val status = conn.responseCode
+
+ if (status == HttpURLConnection.HTTP_OK || status == HttpURLConnection.HTTP_PAYMENT_REQUIRED) {
+ val talerHeader = conn.headerFields["Taler"]
+ if (talerHeader != null && talerHeader[0] != null) {
+ Log.v(TAG, "taler header: ${talerHeader[0]}")
+ val talerHeaderUri = Uri.parse(talerHeader[0])
+ getTalerAction(talerHeaderUri, 0, actionFound)
+ }
+ } else if (status == HttpURLConnection.HTTP_MOVED_TEMP
+ || status == HttpURLConnection.HTTP_MOVED_PERM
+ || status == HttpURLConnection.HTTP_SEE_OTHER
+ ) {
+ val location = conn.headerFields["Location"]
+ if (location != null && location[0] != null) {
+ Log.v(TAG, "location redirect: ${location[0]}")
+ val locUri = Uri.parse(location[0])
+ getTalerAction(locUri, maxRedirects - 1, actionFound)
+ }
+ } else {
+ showError(R.string.error_broken_uri, "$uri")
+ findNavController().popBackStack()
+ }
+ }
+ } else {
+ actionFound.postValue(uri.toString())
+ }
+
+ return actionFound
+ }
+
+ private fun prepareManualWithdrawal(uri: String) {
+ model.showProgressBar.value = true
+ lifecycleScope.launch(Dispatchers.IO) {
+ val response = model.withdrawManager.prepareManualWithdrawal(uri)
+ if (response == null) withContext(Dispatchers.Main) {
+ model.showProgressBar.value = false
+ findNavController().navigate(R.id.errorFragment)
+ } else {
+ val exchange =
+ model.exchangeManager.findExchangeByUrl(response.exchangeBaseUrl)
+ if (exchange == null) withContext(Dispatchers.Main) {
+ model.showProgressBar.value = false
+ showError(R.string.exchange_add_error)
+ findNavController().navigateUp()
+ } else {
+ model.exchangeManager.withdrawalExchange = exchange
+ withContext(Dispatchers.Main) {
+ model.showProgressBar.value = false
+ val args = Bundle().apply {
+ putBoolean("hideScanQr", true)
+ if (response.amount != null) {
+ putString("amount", response.amount.toJSONString())
+ }
+ }
+
+ findNavController().navigate(R.id.action_handleUri_to_manualWithdrawal, args)
+ }
+ }
+ }
+ }
+ }
+
+ private fun onRefundResponse(status: RefundStatus) {
+ model.showProgressBar.value = false
+ when (status) {
+ is RefundStatus.Error -> {
+ if (model.devMode.value == true) {
+ showError(status.error)
+ } else {
+ showError(R.string.refund_error, status.error.userFacingMsg)
+ }
+
+ findNavController().navigateUp()
+ }
+ is RefundStatus.Success -> {
+ lifecycleScope.launch {
+ val transactionId = status.response.transactionId
+ val transaction = model.transactionManager.getTransactionById(transactionId)
+ if (transaction != null) {
+ // TODO: currency what? scopes are the cool thing now
+ // val currency = transaction.amountRaw.currency
+ // model.showTransactions(currency)
+ Snackbar.make(requireView(), getString(R.string.refund_success), LENGTH_LONG).show()
+ }
+
+ findNavController().navigateUp()
+ }
+ }
+
+ }
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/MainActivity.kt
index 13fd394..d15340a 100644
--- a/wallet/src/main/java/net/taler/wallet/MainActivity.kt
+++ b/wallet/src/main/java/net/taler/wallet/MainActivity.kt
@@ -22,9 +22,9 @@ import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.content.IntentFilter
-import android.net.Uri
import android.os.Bundle
import android.util.Log
+import android.view.Menu
import android.view.MenuItem
import android.view.View.GONE
import android.view.View.INVISIBLE
@@ -34,9 +34,6 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.bundleOf
import androidx.core.view.GravityCompat.START
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.Observer
-import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
@@ -45,18 +42,12 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback
import com.google.android.material.navigation.NavigationView.OnNavigationItemSelectedListener
-import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
-import com.google.android.material.snackbar.Snackbar
import com.google.zxing.client.android.Intents.Scan.MIXED_SCAN
import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.journeyapps.barcodescanner.ScanOptions.QR_CODE
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
import net.taler.common.EventObserver
-import net.taler.common.isOnline
-import net.taler.common.showError
import net.taler.wallet.BuildConfig.VERSION_CODE
import net.taler.wallet.BuildConfig.VERSION_NAME
import net.taler.wallet.HostCardEmulatorService.Companion.HTTP_TUNNEL_RESPONSE
@@ -64,12 +55,7 @@ import net.taler.wallet.HostCardEmulatorService.Companion.MERCHANT_NFC_CONNECTED
import net.taler.wallet.HostCardEmulatorService.Companion.MERCHANT_NFC_DISCONNECTED
import net.taler.wallet.HostCardEmulatorService.Companion.TRIGGER_PAYMENT_ACTION
import net.taler.wallet.databinding.ActivityMainBinding
-import net.taler.wallet.refund.RefundStatus
-import java.net.HttpURLConnection
-import java.net.URL
-import java.util.Locale.ROOT
-import javax.net.ssl.HttpsURLConnection
-
+import net.taler.wallet.events.ObservabilityDialog
class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener,
OnPreferenceStartFragmentCallback {
@@ -100,7 +86,7 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener,
setSupportActionBar(ui.content.toolbar)
val appBarConfiguration = AppBarConfiguration(
- setOf(R.id.nav_main, R.id.nav_settings, R.id.nav_pending_operations),
+ setOf(R.id.nav_main, R.id.nav_settings),
ui.drawerLayout
)
ui.content.toolbar.setupWithNavController(nav, appBarConfiguration)
@@ -110,14 +96,13 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener,
}
val versionView: TextView = ui.navView.getHeaderView(0).findViewById(R.id.versionView)
- model.devMode.observe(this) { enabled ->
- ui.navView.menu.findItem(R.id.nav_dev).isVisible = enabled
- if (enabled) {
- @SuppressLint("SetTextI18n")
- versionView.text = "$VERSION_NAME ($VERSION_CODE)"
- versionView.visibility = VISIBLE
- } else versionView.visibility = GONE
- }
+ @SuppressLint("SetTextI18n")
+ versionView.text = "$VERSION_NAME ($VERSION_CODE)"
+
+ // Uncomment if any dev options are added in the future
+ // model.devMode.observe(this) { enabled ->
+ // ui.navView.menu.findItem(R.id.nav_dev).isVisible = enabled
+ // }
if (intent.action == ACTION_VIEW) intent.dataString?.let { uri ->
handleTalerUri(uri, "intent")
@@ -140,8 +125,18 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener,
}
if (it) barcodeLauncher.launch(scanOptions)
})
+
+ model.networkManager.networkStatus.observe(this) { online ->
+ ui.content.offlineBanner.visibility = if (online) GONE else VISIBLE
+ model.hintNetworkAvailability(online)
+ }
+
+ model.devMode.observe(this) {
+ invalidateMenu()
+ }
}
+ @Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (ui.drawerLayout.isDrawerOpen(START)) ui.drawerLayout.closeDrawer(START)
else super.onBackPressed()
@@ -154,147 +149,43 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener,
}
}
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ if (model.devMode.value == true) {
+ menuInflater.inflate(R.menu.global_dev, menu)
+ }
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
override fun onNavigationItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.nav_home -> nav.navigate(R.id.nav_main)
R.id.nav_settings -> nav.navigate(R.id.nav_settings)
- R.id.nav_pending_operations -> nav.navigate(R.id.nav_pending_operations)
}
ui.drawerLayout.closeDrawer(START)
return true
}
- override fun onDestroy() {
- unregisterReceiver(triggerPaymentReceiver)
- unregisterReceiver(nfcConnectedReceiver)
- unregisterReceiver(nfcDisconnectedReceiver)
- unregisterReceiver(tunnelResponseReceiver)
- super.onDestroy()
- }
-
- private fun getTalerAction(
- uri: Uri,
- maxRedirects: Int,
- actionFound: MutableLiveData<String>,
- ): MutableLiveData<String> {
- val scheme = uri.scheme ?: return actionFound
-
- if (scheme == "http" || scheme == "https") {
- model.viewModelScope.launch(Dispatchers.IO) {
- val conn: HttpsURLConnection =
- URL(uri.toString()).openConnection() as HttpsURLConnection
- Log.v(TAG, "prepare query: $uri")
- conn.setRequestProperty("Accept", "text/html")
- conn.connectTimeout = 5000
- conn.requestMethod = "HEAD"
- conn.connect()
- val status = conn.responseCode
-
- if (status == HttpURLConnection.HTTP_OK || status == HttpURLConnection.HTTP_PAYMENT_REQUIRED) {
- val talerHeader = conn.headerFields["Taler"]
- if (talerHeader != null && talerHeader[0] != null) {
- Log.v(TAG, "taler header: ${talerHeader[0]}")
- val talerHeaderUri = Uri.parse(talerHeader[0])
- getTalerAction(talerHeaderUri, 0, actionFound)
- }
- }
- if (status == HttpURLConnection.HTTP_MOVED_TEMP
- || status == HttpURLConnection.HTTP_MOVED_PERM
- || status == HttpURLConnection.HTTP_SEE_OTHER
- ) {
- val location = conn.headerFields["Location"]
- if (location != null && location[0] != null) {
- Log.v(TAG, "location redirect: ${location[0]}")
- val locUri = Uri.parse(location[0])
- getTalerAction(locUri, maxRedirects - 1, actionFound)
- }
- }
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.action_show_logs -> {
+ ObservabilityDialog().show(supportFragmentManager, "OBSERVABILITY")
}
- } else {
- actionFound.postValue(uri.toString())
}
-
- return actionFound
+ return super.onOptionsItemSelected(item)
}
- private fun handleTalerUri(url: String, from: String) {
- val uri = Uri.parse(url)
- if (uri.fragment != null && !isOnline()) {
- connectToWifi(this, uri.fragment!!)
- }
-
- getTalerAction(uri, 3, MutableLiveData<String>()).observe(this) { u ->
- Log.v(TAG, "found action $u")
-
- if (u.startsWith("payto://", ignoreCase = true)) {
- Log.v(TAG, "navigating with paytoUri!")
- val bundle = bundleOf("uri" to u)
- nav.navigate(R.id.action_nav_payto_uri, bundle)
- return@observe
- }
-
- val normalizedURL = u.lowercase(ROOT)
- val action = normalizedURL.substring(
- if (normalizedURL.startsWith("taler://", ignoreCase = true)) {
- "taler://".length
- } else if (normalizedURL.startsWith("taler+http://", ignoreCase = true) &&
- model.devMode.value == true
- ) {
- "taler+http://".length
- } else {
- normalizedURL.length
- }
- )
-
- when {
- action.startsWith("pay/", ignoreCase = true) -> {
- Log.v(TAG, "navigating!")
- nav.navigate(R.id.action_global_promptPayment)
- model.paymentManager.preparePay(u)
- }
- action.startsWith("tip/", ignoreCase = true) -> {
- Log.v(TAG, "navigating!")
- nav.navigate(R.id.action_global_promptTip)
- model.tipManager.prepareTip(u)
- }
- action.startsWith("withdraw/", ignoreCase = true) -> {
- Log.v(TAG, "navigating!")
- // there's more than one entry point, so use global action
- nav.navigate(R.id.action_global_promptWithdraw)
- model.withdrawManager.getWithdrawalDetails(u)
- }
- action.startsWith("refund/", ignoreCase = true) -> {
- model.showProgressBar.value = true
- model.refundManager.refund(u).observe(this, Observer(::onRefundResponse))
- }
- action.startsWith("pay-pull/", ignoreCase = true) -> {
- nav.navigate(R.id.action_global_prompt_pull_payment)
- model.peerManager.checkPeerPullPayment(u)
- }
- action.startsWith("pay-push/", ignoreCase = true) -> {
- nav.navigate(R.id.action_global_prompt_push_payment)
- model.peerManager.checkPeerPushPayment(u)
- }
- else -> {
- showError(R.string.error_unsupported_uri, "From: $from\nURI: $u")
- }
- }
- }
+ private fun handleTalerUri(uri: String, from: String) {
+ val args = bundleOf("uri" to uri, "from" to from)
+ nav.navigate(R.id.action_global_handle_uri, args)
}
- private fun onRefundResponse(status: RefundStatus) {
- model.showProgressBar.value = false
- when (status) {
- is RefundStatus.Error -> {
- showError(R.string.refund_error, status.msg)
- }
- is RefundStatus.Success -> {
- val amount = status.response.amountRefundGranted
- model.showTransactions(amount.currency)
- val str = getString(R.string.refund_success, amount.amountStr)
- Snackbar.make(ui.navView, str, LENGTH_LONG).show()
- }
- }
+ override fun onDestroy() {
+ unregisterReceiver(triggerPaymentReceiver)
+ unregisterReceiver(nfcConnectedReceiver)
+ unregisterReceiver(nfcDisconnectedReceiver)
+ unregisterReceiver(tunnelResponseReceiver)
+ super.onDestroy()
}
private val triggerPaymentReceiver = object : BroadcastReceiver() {
@@ -335,7 +226,6 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener,
pref: Preference,
): Boolean {
when (pref.key) {
- "pref_backup" -> nav.navigate(R.id.action_nav_settings_to_nav_settings_backup)
"pref_exchanges" -> nav.navigate(R.id.action_nav_settings_to_nav_settings_exchanges)
}
return true
diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt
index 2521e29..9fa9838 100644
--- a/wallet/src/main/java/net/taler/wallet/MainFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/MainFragment.kt
@@ -24,19 +24,20 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import net.taler.common.EventObserver
-import net.taler.wallet.CurrencyMode.MULTI
-import net.taler.wallet.CurrencyMode.SINGLE
-import net.taler.wallet.balances.BalanceItem
+import net.taler.wallet.ScopeMode.MULTI
+import net.taler.wallet.ScopeMode.SINGLE
+import net.taler.wallet.balances.BalanceState
+import net.taler.wallet.balances.BalanceState.Success
import net.taler.wallet.balances.BalancesFragment
import net.taler.wallet.databinding.FragmentMainBinding
import net.taler.wallet.transactions.TransactionsFragment
-enum class CurrencyMode { SINGLE, MULTI }
+enum class ScopeMode { SINGLE, MULTI }
class MainFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
- private var currencyMode: CurrencyMode? = null
+ private var scopeMode: ScopeMode? = null
private lateinit var ui: FragmentMainBinding
@@ -50,13 +51,13 @@ class MainFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- model.balances.observe(viewLifecycleOwner) {
+ model.balanceManager.state.observe(viewLifecycleOwner) {
onBalancesChanged(it)
}
- model.transactionsEvent.observe(viewLifecycleOwner, EventObserver { currency ->
- // we only need to navigate to a dedicated list, when in multi-currency mode
- if (currencyMode == MULTI) {
- model.transactionManager.selectedCurrency = currency
+ model.transactionsEvent.observe(viewLifecycleOwner, EventObserver { scopeInfo ->
+ // we only need to navigate to a dedicated list, when in multi-scope mode
+ if (scopeMode == MULTI) {
+ model.transactionManager.selectedScope = scopeInfo
findNavController().navigate(R.id.action_nav_main_to_nav_transactions)
}
})
@@ -72,19 +73,21 @@ class MainFragment : Fragment() {
override fun onStart() {
super.onStart()
- model.loadBalances()
+ model.balanceManager.loadBalances()
}
- private fun onBalancesChanged(balances: List<BalanceItem>) {
+ private fun onBalancesChanged(state: BalanceState) {
+ if (state !is Success) return
+ val balances = state.balances
val mode = if (balances.size == 1) SINGLE else MULTI
- if (currencyMode != mode) {
+ if (scopeMode != mode) {
val f = if (mode == SINGLE) {
- model.transactionManager.selectedCurrency = balances[0].available.currency
+ model.transactionManager.selectedScope = balances[0].scopeInfo
TransactionsFragment()
} else {
BalancesFragment()
}
- currencyMode = mode
+ scopeMode = mode
childFragmentManager.beginTransaction()
.replace(R.id.mainFragmentContainer, f, mode.name)
.commitNow()
diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt
index 255c28b..c7318de 100644
--- a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt
+++ b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt
@@ -22,134 +22,146 @@ import androidx.annotation.UiThread
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.Job
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.launch
+import kotlinx.serialization.encodeToString
import net.taler.common.Amount
import net.taler.common.AmountParserException
import net.taler.common.Event
-import net.taler.common.assertUiThread
import net.taler.common.toEvent
import net.taler.wallet.accounts.AccountManager
+import net.taler.wallet.backend.BackendManager
+import net.taler.wallet.backend.NotificationPayload
+import net.taler.wallet.backend.NotificationReceiver
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.backend.VersionReceiver
import net.taler.wallet.backend.WalletBackendApi
-import net.taler.wallet.balances.BalanceItem
-import net.taler.wallet.balances.BalanceResponse
+import net.taler.wallet.backend.WalletCoreVersion
+import net.taler.wallet.backend.WalletRunConfig
+import net.taler.wallet.backend.WalletRunConfig.Testing
+import net.taler.wallet.balances.BalanceManager
+import net.taler.wallet.balances.ScopeInfo
import net.taler.wallet.deposit.DepositManager
+import net.taler.wallet.events.ObservabilityEvent
import net.taler.wallet.exchanges.ExchangeManager
import net.taler.wallet.payment.PaymentManager
import net.taler.wallet.peer.PeerManager
-import net.taler.wallet.pending.PendingOperationsManager
import net.taler.wallet.refund.RefundManager
import net.taler.wallet.settings.SettingsManager
-import net.taler.wallet.tip.TipManager
import net.taler.wallet.transactions.TransactionManager
import net.taler.wallet.withdraw.WithdrawManager
import org.json.JSONObject
-import java.util.concurrent.TimeUnit.DAYS
-import java.util.concurrent.TimeUnit.MINUTES
-import kotlin.random.Random
const val TAG = "taler-wallet"
+const val OBSERVABILITY_LIMIT = 100
private val transactionNotifications = listOf(
- "proposal-accepted",
- "refresh-revealed",
- "withdraw-group-finished"
+ "transaction-state-transition",
)
-class MainViewModel(val app: Application) : AndroidViewModel(app) {
+private val observabilityNotifications = listOf(
+ "task-observability-event",
+ "request-observability-event",
+)
+
+class MainViewModel(
+ app: Application,
+) : AndroidViewModel(app), VersionReceiver, NotificationReceiver {
- private val mBalances = MutableLiveData<List<BalanceItem>>()
- val balances: LiveData<List<BalanceItem>> = mBalances.distinctUntilChanged()
+ private val mDevMode = MutableLiveData(BuildConfig.DEBUG)
+ val devMode: LiveData<Boolean> = mDevMode
- val devMode = MutableLiveData(BuildConfig.DEBUG)
val showProgressBar = MutableLiveData<Boolean>()
+ var walletVersion: String? = null
+ private set
+ var walletVersionHash: String? = null
+ private set
var exchangeVersion: String? = null
private set
var merchantVersion: String? = null
private set
- private val api = WalletBackendApi(app) { payload ->
- if (payload.optString("operation") == "init") {
- val result = payload.getJSONObject("result")
- val versions = result.getJSONObject("versionInfo")
- exchangeVersion = versions.getString("exchange")
- merchantVersion = versions.getString("merchant")
- } else if (payload.getString("type") != "waiting-for-retry") { // ignore ping
- Log.i(TAG, "Received notification from wallet-core: ${payload.toString(2)}")
- loadBalances()
- if (payload.optString("type") in transactionNotifications) {
- assertUiThread()
- // TODO notification API should give us a currency to update
- // update currently selected transaction list
- transactionManager.loadTransactions()
- }
- // refresh pending ops and history with each notification
- if (devMode.value == true) {
- pendingOperationsManager.getPending()
- }
- }
- }
+ @set:Synchronized
+ private var walletConfig = WalletRunConfig(
+ testing = Testing(
+ emitObservabilityEvents = true,
+ devModeActive = devMode.value ?: false,
+ )
+ )
+
+ private val api = WalletBackendApi(app, walletConfig, this, this)
+ val networkManager = NetworkManager(app.applicationContext)
val withdrawManager = WithdrawManager(api, viewModelScope)
- val tipManager = TipManager(api, viewModelScope)
val paymentManager = PaymentManager(api, viewModelScope)
- val pendingOperationsManager: PendingOperationsManager = PendingOperationsManager(api)
val transactionManager: TransactionManager = TransactionManager(api, viewModelScope)
val refundManager = RefundManager(api, viewModelScope)
+ val balanceManager = BalanceManager(api, viewModelScope)
val exchangeManager: ExchangeManager = ExchangeManager(api, viewModelScope)
- val peerManager: PeerManager = PeerManager(api, viewModelScope)
- val settingsManager: SettingsManager = SettingsManager(app.applicationContext, viewModelScope)
+ val peerManager: PeerManager = PeerManager(api, exchangeManager, viewModelScope)
+ val settingsManager: SettingsManager = SettingsManager(app.applicationContext, api, viewModelScope)
val accountManager: AccountManager = AccountManager(api, viewModelScope)
val depositManager: DepositManager = DepositManager(api, viewModelScope)
- private val mTransactionsEvent = MutableLiveData<Event<String>>()
- val transactionsEvent: LiveData<Event<String>> = mTransactionsEvent
+ private val mTransactionsEvent = MutableLiveData<Event<ScopeInfo>>()
+ val transactionsEvent: LiveData<Event<ScopeInfo>> = mTransactionsEvent
+
+ private val mObservabilityLog = MutableStateFlow<List<ObservabilityEvent>>(emptyList())
+ val observabilityLog: StateFlow<List<ObservabilityEvent>> = mObservabilityLog
private val mScanCodeEvent = MutableLiveData<Event<Boolean>>()
val scanCodeEvent: LiveData<Event<Boolean>> = mScanCodeEvent
- private val mLastBackup = MutableLiveData(
- // fake backup time until we actually do backup
- System.currentTimeMillis() -
- Random.nextLong(MINUTES.toMillis(5), DAYS.toMillis(2))
- )
- val lastBackup: LiveData<Long> = mLastBackup
-
- override fun onCleared() {
- api.destroy()
- super.onCleared()
+ override fun onVersionReceived(versionInfo: WalletCoreVersion) {
+ walletVersion = versionInfo.implementationSemver
+ walletVersionHash = versionInfo.implementationGitHash
+ exchangeVersion = versionInfo.exchange
+ merchantVersion = versionInfo.merchant
}
- @UiThread
- fun loadBalances(): Job = viewModelScope.launch {
- showProgressBar.value = true
- val response = api.request("getBalances", BalanceResponse.serializer())
- showProgressBar.value = false
- response.onError {
- // TODO expose in UI
- Log.e(TAG, "Error retrieving balances: $it")
+ override fun onNotificationReceived(payload: NotificationPayload) {
+ if (payload.type == "waiting-for-retry") return // ignore ping)
+
+ val str = BackendManager.json.encodeToString(payload)
+ Log.i(TAG, "Received notification from wallet-core: $str")
+
+ // Only update balances when we're told they changed
+ if (payload.type == "balance-change") viewModelScope.launch(Dispatchers.Main) {
+ balanceManager.loadBalances()
}
- response.onSuccess {
- mBalances.value = it.balances
+
+ if (payload.type in observabilityNotifications && payload.event != null) {
+ mObservabilityLog.getAndUpdate { logs ->
+ logs.takeLast(OBSERVABILITY_LIMIT)
+ .toMutableList().apply {
+ add(payload.event)
+ }
+ }
+ }
+
+ if (payload.type in transactionNotifications) viewModelScope.launch(Dispatchers.Main) {
+ // TODO notification API should give us a currency to update
+ // update currently selected transaction list
+ transactionManager.loadTransactions()
}
}
/**
- * Navigates to the given currency's transaction list, when [MainFragment] is shown.
+ * Navigates to the given scope info's transaction list, when [MainFragment] is shown.
*/
@UiThread
- fun showTransactions(currency: String) {
- mTransactionsEvent.value = currency.toEvent()
+ fun showTransactions(scopeInfo: ScopeInfo) {
+ mTransactionsEvent.value = scopeInfo.toEvent()
}
@UiThread
- fun getCurrencies(): List<String> {
- return balances.value?.map { balanceItem ->
- balanceItem.currency
- } ?: emptyList()
- }
+ fun getCurrencies() = balanceManager.balances.value?.map { balanceItem ->
+ balanceItem.currency
+ } ?: emptyList()
@UiThread
fun createAmount(amountText: String, currency: String): AmountResult {
@@ -164,7 +176,7 @@ class MainViewModel(val app: Application) : AndroidViewModel(app) {
@UiThread
fun hasSufficientBalance(amount: Amount): Boolean {
- balances.value?.forEach { balanceItem ->
+ balanceManager.balances.value?.forEach { balanceItem ->
if (balanceItem.currency == amount.currency) {
return balanceItem.available >= amount
}
@@ -174,22 +186,26 @@ class MainViewModel(val app: Application) : AndroidViewModel(app) {
@UiThread
fun dangerouslyReset() {
- api.sendRequest("reset")
withdrawManager.testWithdrawalStatus.value = null
- mBalances.value = emptyList()
+ balanceManager.resetBalances()
}
fun startTunnel() {
- api.sendRequest("startTunnel")
+ viewModelScope.launch {
+ api.sendRequest("startTunnel")
+ }
}
fun stopTunnel() {
- api.sendRequest("stopTunnel")
+ viewModelScope.launch {
+ api.sendRequest("stopTunnel")
+ }
}
fun tunnelResponse(resp: String) {
- val respJson = JSONObject(resp)
- api.sendRequest("tunnelResponse", respJson)
+ viewModelScope.launch {
+ api.sendRequest("tunnelResponse", JSONObject(resp))
+ }
}
@UiThread
@@ -197,6 +213,53 @@ class MainViewModel(val app: Application) : AndroidViewModel(app) {
mScanCodeEvent.value = true.toEvent()
}
+ fun setDevMode(enabled: Boolean, onError: (error: TalerErrorInfo) -> Unit) {
+ mDevMode.postValue(enabled)
+ viewModelScope.launch {
+ val config = walletConfig.copy(
+ testing = walletConfig.testing?.copy(
+ devModeActive = enabled,
+ ) ?: Testing(
+ devModeActive = enabled,
+ ),
+ )
+
+ api.setWalletConfig(config)
+ .onSuccess {
+ walletConfig = config
+ }.onError(onError)
+ }
+ }
+
+ fun hintNetworkAvailability(isAvailable: Boolean) {
+ viewModelScope.launch {
+ api.request<Unit>("hintNetworkAvailability") {
+ put("isNetworkAvailable", isAvailable)
+ }
+ }
+ }
+
+ fun runIntegrationTest() {
+ viewModelScope.launch {
+ api.request<Unit>("runIntegrationTestV2") {
+ put("amountToWithdraw", "KUDOS:42")
+ put("amountToSpend", "KUDOS:23")
+ put("corebankApiBaseUrl", "https://bank.demo.taler.net/")
+ put("exchangeBaseUrl", "https://exchange.demo.taler.net/")
+ put("merchantBaseUrl", "https://backend.demo.taler.net/")
+ put("merchantAuthToken", "secret-token:sandbox")
+ }
+ }
+ }
+
+ fun applyDevExperiment(uri: String, onError: (error: TalerErrorInfo) -> Unit) {
+ viewModelScope.launch {
+ api.request<Unit>("applyDevExperiment") {
+ put("devExperimentUri", uri)
+ }.onError(onError)
+ }
+ }
+
}
sealed class AmountResult {
diff --git a/wallet/src/main/java/net/taler/wallet/NetworkManager.kt b/wallet/src/main/java/net/taler/wallet/NetworkManager.kt
new file mode 100644
index 0000000..a45ad48
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/NetworkManager.kt
@@ -0,0 +1,64 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet
+
+import android.content.Context
+import android.content.Context.CONNECTIVITY_SERVICE
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+
+class NetworkManager(context: Context) : ConnectivityManager.NetworkCallback() {
+ private val connectivityManager: ConnectivityManager
+
+ private val _networkStatus: MutableLiveData<Boolean>
+ val networkStatus: LiveData<Boolean>
+
+ init {
+ // careful, the order below is important, should probably get simplified
+ connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
+ _networkStatus = MutableLiveData(getCurrentStatus())
+ networkStatus = _networkStatus
+ connectivityManager.registerDefaultNetworkCallback(this)
+ }
+
+ @UiThread
+ override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
+ super.onCapabilitiesChanged(network, networkCapabilities)
+ _networkStatus.postValue(networkCapabilities.isOnline())
+ }
+
+ override fun onLost(network: Network) {
+ super.onLost(network)
+ _networkStatus.postValue(getCurrentStatus())
+ }
+
+ private fun getCurrentStatus(): Boolean {
+ return connectivityManager.activeNetwork?.let { network ->
+ connectivityManager.getNetworkCapabilities(network)?.isOnline()
+ } ?: false
+ }
+
+ private fun NetworkCapabilities.isOnline(): Boolean {
+ return hasCapability(NET_CAPABILITY_INTERNET) && hasCapability(NET_CAPABILITY_VALIDATED)
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt
index ac6b0bd..25d35ec 100644
--- a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt
@@ -29,13 +29,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.OutlinedTextField
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -43,10 +41,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.KeyboardType.Companion.Decimal
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
@@ -55,13 +51,19 @@ import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import net.taler.common.Amount
-import net.taler.common.Amount.Companion.isValidAmountStr
+import net.taler.common.CurrencySpecification
+import net.taler.wallet.compose.AmountInputField
+import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS
import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.exchanges.ExchangeItem
class ReceiveFundsFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
private val exchangeManager get() = model.exchangeManager
+ private val withdrawManager get() = model.withdrawManager
+ private val balanceManager get() = model.balanceManager
+ private val peerManager get() = model.peerManager
+ private val scopeInfo get() = model.transactionManager.selectedScope ?: error("No scope selected")
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
@@ -70,7 +72,8 @@ class ReceiveFundsFragment : Fragment() {
setContent {
TalerSurface {
ReceiveFundsIntro(
- model.transactionManager.selectedCurrency ?: error("No currency selected"),
+ scopeInfo.currency,
+ balanceManager.getSpecForScopeInfo(scopeInfo),
this@ReceiveFundsFragment::onManualWithdraw,
this@ReceiveFundsFragment::onPeerPull,
)
@@ -80,7 +83,7 @@ class ReceiveFundsFragment : Fragment() {
override fun onStart() {
super.onStart()
- activity?.setTitle(R.string.transactions_receive_funds)
+ activity?.setTitle(getString(R.string.transactions_receive_funds_title, scopeInfo.currency))
}
private fun onManualWithdraw(amount: Amount) {
@@ -98,15 +101,16 @@ class ReceiveFundsFragment : Fragment() {
Toast.makeText(requireContext(), "No exchange available", LENGTH_LONG).show()
return
}
- exchangeManager.withdrawalExchange = exchange
+
// now that we have the exchange, we can navigate
- val bundle = bundleOf("amount" to amount.toJSONString())
- findNavController().navigate(
- R.id.action_receiveFunds_to_nav_exchange_manual_withdrawal, bundle)
+ exchangeManager.withdrawalExchange = exchange
+ withdrawManager.getWithdrawalDetails(exchange.exchangeBaseUrl, amount)
+ findNavController().navigate(R.id.action_receiveFunds_to_nav_prompt_withdraw)
}
private fun onPeerPull(amount: Amount) {
val bundle = bundleOf("amount" to amount.toJSONString())
+ peerManager.checkPeerPullCredit(amount)
findNavController().navigate(R.id.action_receiveFunds_to_nav_peer_pull, bundle)
}
}
@@ -114,6 +118,7 @@ class ReceiveFundsFragment : Fragment() {
@Composable
private fun ReceiveFundsIntro(
currency: String,
+ spec: CurrencySpecification?,
onManualWithdraw: (Amount) -> Unit,
onPeerPull: (Amount) -> Unit,
) {
@@ -123,47 +128,40 @@ private fun ReceiveFundsIntro(
.fillMaxWidth()
.verticalScroll(scrollState),
) {
- var text by rememberSaveable { mutableStateOf("") }
+ var text by rememberSaveable { mutableStateOf("0") }
var isError by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(16.dp),
) {
- OutlinedTextField(
+ AmountInputField(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
value = text,
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType = Decimal),
onValueChange = { input ->
isError = false
- val filtered = input.filter { it.isDigit() || it == '.' }
- if (filtered.endsWith('.') || isValidAmountStr(filtered)) text = filtered
+ text = input
+ },
+ label = { Text(stringResource(R.string.amount_receive)) },
+ supportingText = {
+ if (isError) Text(stringResource(R.string.amount_invalid))
},
isError = isError,
- label = {
- if (isError) {
- Text(
- stringResource(R.string.receive_amount_invalid),
- color = Color.Red,
- )
- } else {
- Text(stringResource(R.string.receive_amount))
- }
- }
+ numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS,
)
Text(
modifier = Modifier,
- text = currency,
+ text = spec?.symbol ?: currency,
softWrap = false,
- style = MaterialTheme.typography.h6,
+ style = MaterialTheme.typography.titleLarge,
)
}
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = stringResource(R.string.receive_intro),
- style = MaterialTheme.typography.h6,
+ style = MaterialTheme.typography.titleLarge,
)
Row(modifier = Modifier.padding(16.dp)) {
Button(
@@ -173,7 +171,7 @@ private fun ReceiveFundsIntro(
.weight(1f),
onClick = {
val amount = getAmount(currency, text)
- if (amount == null) isError = true
+ if (amount == null || amount.isZero()) isError = true
else onManualWithdraw(amount)
}) {
Text(text = stringResource(R.string.receive_withdraw))
@@ -184,7 +182,7 @@ private fun ReceiveFundsIntro(
.height(IntrinsicSize.Max),
onClick = {
val amount = getAmount(currency, text)
- if (amount == null) isError = true
+ if (amount == null || amount.isZero()) isError = true
else onPeerPull(amount)
},
) {
@@ -198,6 +196,6 @@ private fun ReceiveFundsIntro(
@Composable
fun PreviewReceiveFundsIntro() {
Surface {
- ReceiveFundsIntro("TESTKUDOS", {}) {}
+ ReceiveFundsIntro("TESTKUDOS", null, {}) {}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt b/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt
index 90b4ee0..ca72a64 100644
--- a/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt
@@ -27,13 +27,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.OutlinedTextField
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -41,10 +39,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
@@ -52,12 +48,16 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import net.taler.common.Amount
-import net.taler.common.Amount.Companion.isValidAmountStr
+import net.taler.common.CurrencySpecification
+import net.taler.wallet.compose.AmountInputField
+import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS
import net.taler.wallet.compose.TalerSurface
class SendFundsFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
+ private val balanceManager get() = model.balanceManager
private val peerManager get() = model.peerManager
+ private val scopeInfo get() = model.transactionManager.selectedScope ?: error("No scope selected")
override fun onCreateView(
inflater: LayoutInflater,
@@ -67,8 +67,8 @@ class SendFundsFragment : Fragment() {
setContent {
TalerSurface {
SendFundsIntro(
- currency = model.transactionManager.selectedCurrency
- ?: error("No currency selected"),
+ currency = scopeInfo.currency,
+ spec = balanceManager.getSpecForScopeInfo(scopeInfo),
hasSufficientBalance = model::hasSufficientBalance,
onDeposit = this@SendFundsFragment::onDeposit,
onPeerPush = this@SendFundsFragment::onPeerPush,
@@ -79,21 +79,17 @@ class SendFundsFragment : Fragment() {
override fun onStart() {
super.onStart()
- activity?.setTitle(R.string.transactions_send_funds)
+ activity?.setTitle(getString(R.string.transactions_send_funds_title, scopeInfo.currency))
}
- override fun onDestroy() {
- super.onDestroy()
- if (!requireActivity().isChangingConfigurations) peerManager.resetPushPayment()
- }
-
- fun onDeposit(amount: Amount) {
+ private fun onDeposit(amount: Amount) {
val bundle = bundleOf("amount" to amount.toJSONString())
findNavController().navigate(R.id.action_sendFunds_to_nav_deposit, bundle)
}
- fun onPeerPush(amount: Amount) {
+ private fun onPeerPush(amount: Amount) {
val bundle = bundleOf("amount" to amount.toJSONString())
+ peerManager.checkPeerPushDebit(amount)
findNavController().navigate(R.id.action_sendFunds_to_nav_peer_push, bundle)
}
}
@@ -101,6 +97,7 @@ class SendFundsFragment : Fragment() {
@Composable
private fun SendFundsIntro(
currency: String,
+ spec: CurrencySpecification?,
hasSufficientBalance: (Amount) -> Boolean,
onDeposit: (Amount) -> Unit,
onPeerPush: (Amount) -> Unit,
@@ -111,7 +108,7 @@ private fun SendFundsIntro(
.fillMaxWidth()
.verticalScroll(scrollState),
) {
- var text by rememberSaveable { mutableStateOf("") }
+ var text by rememberSaveable { mutableStateOf("0") }
var isError by rememberSaveable { mutableStateOf(false) }
var insufficientBalance by rememberSaveable { mutableStateOf(false) }
Row(
@@ -119,57 +116,49 @@ private fun SendFundsIntro(
modifier = Modifier
.padding(16.dp),
) {
- OutlinedTextField(
+ AmountInputField(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
value = text,
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Decimal),
onValueChange = { input ->
isError = false
insufficientBalance = false
- val filtered = input.filter { it.isDigit() || it == '.' }
- if (filtered.endsWith('.') || isValidAmountStr(filtered)) text = filtered
+ text = input
},
- isError = isError || insufficientBalance,
- label = {
- if (isError) {
- Text(
- stringResource(R.string.receive_amount_invalid),
- color = Color.Red,
- )
- } else if (insufficientBalance) {
- Text(
- stringResource(R.string.payment_balance_insufficient),
- color = Color.Red,
- )
- } else {
- Text(stringResource(R.string.send_amount))
+ label = { Text(stringResource(R.string.amount_send)) },
+ supportingText = {
+ if (isError) Text(stringResource(R.string.amount_invalid))
+ else if (insufficientBalance) {
+ Text(stringResource(R.string.payment_balance_insufficient))
}
- }
+ },
+ isError = isError || insufficientBalance,
+ numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS,
)
Text(
modifier = Modifier,
- text = currency,
+ text = spec?.symbol ?: currency,
softWrap = false,
- style = MaterialTheme.typography.h6,
+ style = MaterialTheme.typography.titleLarge,
)
}
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = stringResource(R.string.send_intro),
- style = MaterialTheme.typography.h6,
+ style = MaterialTheme.typography.titleLarge,
)
Row(modifier = Modifier.padding(16.dp)) {
fun onClickButton(block: (Amount) -> Unit) {
val amount = getAmount(currency, text)
- if (amount == null) isError = true
+ if (amount == null || amount.isZero()) isError = true
else if (!hasSufficientBalance(amount)) insufficientBalance = true
else block(amount)
}
Button(
modifier = Modifier
.padding(end = 16.dp)
+ .height(IntrinsicSize.Max)
.weight(1f),
onClick = {
onClickButton { amount -> onDeposit(amount) }
@@ -202,6 +191,6 @@ private fun SendFundsIntro(
@Composable
fun PreviewSendFundsIntro() {
Surface {
- SendFundsIntro("TESTKUDOS", { true }, {}) {}
+ SendFundsIntro("TESTKUDOS", null, { true }, {}) {}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/UriInputFragment.kt b/wallet/src/main/java/net/taler/wallet/UriInputFragment.kt
index c65c53a..63a46a4 100644
--- a/wallet/src/main/java/net/taler/wallet/UriInputFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/UriInputFragment.kt
@@ -56,10 +56,11 @@ class UriInputFragment : Fragment() {
}
}
ui.okButton.setOnClickListener {
- if (ui.uriView.text?.startsWith("taler://", ignoreCase = true) == true ||
- ui.uriView.text?.startsWith("payto://", ignoreCase = true) == true) {
+ val trimmedText = ui.uriView.text?.trim()
+ if (trimmedText?.startsWith("taler://", ignoreCase = true) == true ||
+ trimmedText?.startsWith("payto://", ignoreCase = true) == true) {
ui.uriLayout.error = null
- launchInAppBrowser(requireContext(), ui.uriView.text.toString())
+ launchInAppBrowser(requireContext(), trimmedText.toString())
} else {
ui.uriLayout.error = getString(R.string.uri_invalid)
}
diff --git a/wallet/src/main/java/net/taler/wallet/Utils.kt b/wallet/src/main/java/net/taler/wallet/Utils.kt
index ed1899f..5c4fedc 100644
--- a/wallet/src/main/java/net/taler/wallet/Utils.kt
+++ b/wallet/src/main/java/net/taler/wallet/Utils.kt
@@ -26,17 +26,21 @@ import android.net.wifi.WifiConfiguration
import android.net.wifi.WifiManager
import android.net.wifi.WifiNetworkSpecifier
import android.os.Build.VERSION.SDK_INT
+import android.util.TypedValue
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.annotation.RequiresApi
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.getSystemService
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
import net.taler.common.Amount
import net.taler.common.AmountParserException
+import net.taler.common.showError
import net.taler.common.startActivitySafe
-import net.taler.wallet.backend.TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED
import net.taler.wallet.backend.TalerErrorInfo
-import net.taler.wallet.transactions.Transaction
const val CURRENCY_BTC = "BITCOINBTC"
@@ -103,13 +107,10 @@ fun getAmount(currency: String, text: String): Amount? {
}
}
-fun <T> Transaction.handleKyc(notRequired: () -> T, required: (TalerErrorInfo) -> T): T {
- return error?.let { error ->
- when (error.code) {
- WALLET_WITHDRAWAL_KYC_REQUIRED -> required(error)
- else -> notRequired()
- }
- } ?: notRequired()
+fun Context.getAttrColor(attr: Int): Int {
+ val value = TypedValue()
+ theme.resolveAttribute(attr, value, true)
+ return value.data
}
fun launchInAppBrowser(context: Context, url: String) {
@@ -117,4 +118,30 @@ fun launchInAppBrowser(context: Context, url: String) {
val intent = builder.build().intent
intent.data = Uri.parse(url)
context.startActivitySafe(intent)
+}
+
+fun Fragment.showError(error: TalerErrorInfo) {
+ @Suppress("OPT_IN_USAGE")
+ val json = Json {
+ prettyPrint = true
+ prettyPrintIndent = " "
+ }
+ val message = json.encodeToString(error)
+ showError(message)
+}
+
+fun FragmentActivity.showError(error: TalerErrorInfo) {
+ @Suppress("OPT_IN_USAGE")
+ val json = Json {
+ prettyPrint = true
+ prettyPrintIndent = " "
+ }
+ val message = json.encodeToString(error)
+ showError(message)
+}
+
+fun Context.getThemeColor(attr: Int): Int {
+ val typedValue = TypedValue()
+ theme.resolveAttribute(attr, typedValue, true)
+ return typedValue.data
} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceResponse.kt b/wallet/src/main/java/net/taler/wallet/WalletApp.kt
index d1a111f..1384f76 100644
--- a/wallet/src/main/java/net/taler/wallet/balances/BalanceResponse.kt
+++ b/wallet/src/main/java/net/taler/wallet/WalletApp.kt
@@ -1,6 +1,6 @@
/*
* This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
+ * (C) 2023 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
@@ -14,11 +14,14 @@
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-package net.taler.wallet.balances
+package net.taler.wallet
-import kotlinx.serialization.Serializable
+import android.app.Application
+import com.google.android.material.color.DynamicColors
-@Serializable
-data class BalanceResponse(
- val balances: List<BalanceItem>
-)
+class WalletApp : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ DynamicColors.applyToActivitiesIfAvailable(this)
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt
new file mode 100644
index 0000000..def4668
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt
@@ -0,0 +1,63 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.backend
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonObject
+import net.taler.wallet.events.ObservabilityEvent
+
+@Serializable
+sealed class ApiMessage {
+
+ @Serializable
+ @SerialName("notification")
+ data class Notification(
+ val payload: NotificationPayload,
+ ) : ApiMessage()
+
+}
+
+@Serializable
+data class NotificationPayload(
+ val type: String,
+ val id: String? = null,
+ val event: ObservabilityEvent? = null,
+)
+
+@Serializable
+sealed class ApiResponse : ApiMessage() {
+
+ abstract val id: Int
+ abstract val operation: String
+
+ @Serializable
+ @SerialName("response")
+ data class Response(
+ override val id: Int,
+ override val operation: String,
+ val result: JsonObject,
+ ) : ApiResponse()
+
+ @Serializable
+ @SerialName("error")
+ data class Error(
+ override val id: Int,
+ override val operation: String,
+ val error: JsonObject,
+ ) : ApiResponse()
+}
diff --git a/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt b/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt
new file mode 100644
index 0000000..9292ef5
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt
@@ -0,0 +1,95 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2022 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.backend
+
+import android.util.Log
+import kotlinx.serialization.json.Json
+import net.taler.qtart.TalerWalletCore
+import net.taler.wallet.BuildConfig
+import org.json.JSONObject
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+
+fun interface NotificationReceiver {
+ fun onNotificationReceived(payload: NotificationPayload)
+}
+
+class BackendManager(
+ private val notificationReceiver: NotificationReceiver,
+) {
+
+ companion object {
+ private const val TAG = "BackendManager"
+ private const val TAG_CORE = "taler-wallet-embedded"
+ val json = Json {
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+ }
+ @JvmStatic
+ private val initialized = AtomicBoolean(false)
+ }
+
+ private val walletCore = TalerWalletCore()
+ private val requestManager = RequestManager()
+
+ init {
+ // TODO using Dagger/Hilt and @Singleton would be nice as well
+ if (initialized.getAndSet(true)) error("Already initialized")
+ walletCore.setMessageHandler { onMessageReceived(it) }
+ walletCore.setCurlHttpClient()
+ if (BuildConfig.DEBUG) walletCore.setStdoutHandler {
+ Log.d(TAG_CORE, it)
+ }
+ }
+
+ fun run() {
+ walletCore.run()
+ }
+
+ suspend fun send(operation: String, args: JSONObject? = null): ApiResponse =
+ suspendCoroutine { cont ->
+ requestManager.addRequest(cont) { id ->
+ val request = JSONObject().apply {
+ put("id", id)
+ put("operation", operation)
+ if (args != null) put("args", args)
+ }
+ Log.d(TAG, "sending message:\n${request.toString(2)}")
+ walletCore.sendRequest(request.toString())
+ }
+ }
+
+ private fun onMessageReceived(msg: String) {
+ Log.d(TAG, "message received: $msg")
+ when (val message = json.decodeFromString<ApiMessage>(msg)) {
+ is ApiMessage.Notification -> {
+ notificationReceiver.onNotificationReceived(message.payload)
+ }
+ is ApiResponse -> {
+ val id = message.id
+ val cont = requestManager.getAndRemoveContinuation(id)
+ if (cont == null) {
+ Log.e(TAG, "wallet returned unknown request ID ($id)")
+ } else {
+ cont.resume(message)
+ }
+ }
+ }
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt
new file mode 100644
index 0000000..7fe1a6b
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt
@@ -0,0 +1,84 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.backend
+
+import kotlinx.serialization.Serializable
+import net.taler.wallet.exchanges.BuiltinExchange
+
+@Serializable
+data class InitResponse(
+ val versionInfo: WalletCoreVersion,
+)
+
+@Serializable
+data class WalletRunConfig(
+ val builtin: Builtin? = Builtin(),
+ val testing: Testing? = Testing(),
+ val features: Features? = Features(),
+) {
+ /**
+ * Initialization values useful for a complete startup.
+ *
+ * These are values may be overridden by different wallets
+ */
+ @Serializable
+ data class Builtin(
+ val exchanges: List<BuiltinExchange> = emptyList(),
+ )
+
+ /**
+ * Unsafe options which it should only be used to create
+ * testing environment.
+ */
+ @Serializable
+ data class Testing(
+ /**
+ * Allow withdrawal of denominations even though they are about to expire.
+ */
+ val denomselAllowLate: Boolean = false,
+ val devModeActive: Boolean = false,
+ val insecureTrustExchange: Boolean = false,
+ val preventThrottling: Boolean = false,
+ val skipDefaults: Boolean = false,
+ val emitObservabilityEvents: Boolean? = false,
+ )
+
+ /**
+ * Configurations values that may be safe to show to the user
+ */
+ @Serializable
+ data class Features(
+ val allowHttp: Boolean = false,
+ )
+}
+
+fun interface VersionReceiver {
+ fun onVersionReceived(versionInfo: WalletCoreVersion)
+}
+
+@Serializable
+data class WalletCoreVersion(
+ val implementationSemver: String,
+ val implementationGitHash: String,
+ val version: String,
+ val exchange: String,
+ val merchant: String,
+ val bankIntegrationApiRange: String,
+ val bankConversionApiRange: String,
+ val corebankApiRange: String,
+ val devMode: Boolean,
+)
diff --git a/wallet/src/main/java/net/taler/wallet/backend/RequestManager.kt b/wallet/src/main/java/net/taler/wallet/backend/RequestManager.kt
new file mode 100644
index 0000000..041656e
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/backend/RequestManager.kt
@@ -0,0 +1,44 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.backend
+
+import androidx.annotation.GuardedBy
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.coroutines.Continuation
+
+class RequestManager {
+
+ @GuardedBy("this")
+ private val contMap = ConcurrentHashMap<Int, Continuation<ApiResponse>>()
+
+ @Volatile
+ @GuardedBy("this")
+ private var currentId = 0
+
+ @Synchronized
+ fun addRequest(cont: Continuation<ApiResponse>, block: (Int) -> Unit) {
+ val id = currentId++
+ contMap[id] = cont
+ block(id)
+ }
+
+ @Synchronized
+ fun getAndRemoveContinuation(id: Int): Continuation<ApiResponse>? {
+ return contMap.remove(id)
+ }
+
+}
diff --git a/wallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt b/wallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt
index edcfd17..2242e33 100644
--- a/wallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt
+++ b/wallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt
@@ -3879,9 +3879,10 @@ enum class TalerErrorCode(val code: Int) {
@OptIn(ExperimentalSerializationApi::class)
@Serializer(forClass = TalerErrorCode::class)
-object TalerErrorCodeSerializer: KSerializer<TalerErrorCode> {
+object TalerErrorCodeSerializer : KSerializer<TalerErrorCode> {
- override val descriptor = PrimitiveSerialDescriptor("TalerErrorCodeSerializer", PrimitiveKind.INT)
+ override val descriptor =
+ PrimitiveSerialDescriptor("TalerErrorCodeSerializer", PrimitiveKind.INT)
override fun deserialize(decoder: Decoder): TalerErrorCode {
val code = decoder.decodeInt()
diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt
index 9fc5cac..fba9885 100644
--- a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt
+++ b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt
@@ -17,130 +17,66 @@
package net.taler.wallet.backend
import android.app.Application
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.content.ServiceConnection
-import android.os.Handler
-import android.os.IBinder
-import android.os.Message
-import android.os.Messenger
-import android.util.Log
+import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
-import kotlinx.serialization.json.Json
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.decodeFromJsonElement
import net.taler.wallet.backend.TalerErrorCode.NONE
-import net.taler.wallet.backend.WalletBackendService.Companion.MSG_COMMAND
-import net.taler.wallet.backend.WalletBackendService.Companion.MSG_NOTIFY
-import net.taler.wallet.backend.WalletBackendService.Companion.MSG_REPLY
-import net.taler.wallet.backend.WalletBackendService.Companion.MSG_SUBSCRIBE_NOTIFY
import org.json.JSONObject
-import java.lang.ref.WeakReference
-import java.util.LinkedList
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicInteger
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
+import java.io.File
+private const val WALLET_DB = "talerwalletdb.sqlite3"
+
+@OptIn(DelicateCoroutinesApi::class)
class WalletBackendApi(
private val app: Application,
- private val notificationHandler: ((payload: JSONObject) -> Unit),
+ private val initialConfig: WalletRunConfig,
+ private val versionReceiver: VersionReceiver,
+ notificationReceiver: NotificationReceiver,
) {
- private var walletBackendMessenger: Messenger? = null
- private val queuedMessages = LinkedList<Message>()
- private val handlers = ConcurrentHashMap<Int, (isError: Boolean, message: JSONObject) -> Unit>()
- private var nextRequestID = AtomicInteger(0)
- private val incomingMessenger = Messenger(IncomingHandler(this))
- private val walletBackendConn = object : ServiceConnection {
- override fun onServiceDisconnected(p0: ComponentName?) {
- Log.w(TAG, "wallet backend service disconnected (crash?)")
- walletBackendMessenger = null
- }
+ private val backendManager = BackendManager(notificationReceiver)
- override fun onServiceConnected(componentName: ComponentName?, binder: IBinder?) {
- Log.i(TAG, "connected to wallet backend service")
- val bm = Messenger(binder)
- walletBackendMessenger = bm
- pumpQueue(bm)
- val msg = Message.obtain(null, MSG_SUBSCRIBE_NOTIFY)
- msg.replyTo = incomingMessenger
- bm.send(msg)
+ init {
+ GlobalScope.launch(Dispatchers.IO) {
+ backendManager.run()
+ sendInitMessage()
}
}
- init {
- Intent(app, WalletBackendService::class.java).also { intent ->
- app.bindService(intent, walletBackendConn, Context.BIND_AUTO_CREATE)
+ private suspend fun sendInitMessage() {
+ val db = if (File(app.filesDir, "talerwalletdb.sql").isFile) {
+ // can be removed after a reasonable migration period (2024-02-02)
+ "${app.filesDir}/talerwalletdb.sql"
+ } else {
+ "${app.filesDir}/${WALLET_DB}"
}
- }
- private class IncomingHandler(strongApi: WalletBackendApi) : Handler() {
- private val weakApi = WeakReference(strongApi)
- override fun handleMessage(msg: Message) {
- val api = weakApi.get() ?: return
- when (msg.what) {
- MSG_REPLY -> {
- val requestID = msg.data.getInt("requestID", 0)
- val operation = msg.data.getString("operation", "??")
- Log.i(TAG, "got reply for operation $operation ($requestID)")
- val h = api.handlers.remove(requestID)
- if (h == null) {
- Log.e(TAG, "request ID not associated with a handler")
- return
- }
- val response = msg.data.getString("response")
- if (response == null) {
- Log.e(TAG, "response did not contain response payload")
- return
- }
- val isError = msg.data.getBoolean("isError")
- val json = JSONObject(response)
- h(isError, json)
- }
- MSG_NOTIFY -> {
- val payloadStr = msg.data.getString("payload")
- if (payloadStr == null) {
- Log.e(TAG, "Notification had no payload: $msg")
- } else {
- val payload = JSONObject(payloadStr)
- api.notificationHandler.invoke(payload)
- }
- }
- }
+ request("init", InitResponse.serializer()) {
+ put("persistentStoragePath", db)
+ put("logLevel", "INFO")
+ put("config", JSONObject(BackendManager.json.encodeToString(initialConfig)))
+ }.onSuccess { response ->
+ versionReceiver.onVersionReceived(response.versionInfo)
+ }.onError { error ->
+ // TODO expose this to the UI as it can happen when using an older DB version
+ error("Error on init message: $error")
}
}
- private fun pumpQueue(bm: Messenger) {
- while (true) {
- val msg = queuedMessages.pollFirst() ?: return
- bm.send(msg)
+ suspend fun setWalletConfig(config: WalletRunConfig): WalletResponse<InitResponse> {
+ return request("initWallet", InitResponse.serializer()) {
+ put("config", JSONObject(BackendManager.json.encodeToString(config)))
}
}
- fun sendRequest(
- operation: String,
- args: JSONObject? = null,
- onResponse: (isError: Boolean, message: JSONObject) -> Unit = { _, _ -> },
- ) {
- val requestID = nextRequestID.incrementAndGet()
- Log.i(TAG, "sending request for operation $operation ($requestID)\n${args?.toString(2)}")
- val msg = Message.obtain(null, MSG_COMMAND)
- handlers[requestID] = onResponse
- msg.replyTo = incomingMessenger
- val data = msg.data
- data.putString("operation", operation)
- data.putInt("requestID", requestID)
- if (args != null) {
- data.putString("args", args.toString())
- }
- val bm = walletBackendMessenger
- if (bm != null) {
- bm.send(msg)
- } else {
- queuedMessages.add(msg)
- }
+ suspend fun sendRequest(operation: String, args: JSONObject? = null): ApiResponse {
+ return backendManager.send(operation, args)
}
suspend inline fun <reified T> request(
@@ -148,36 +84,47 @@ class WalletBackendApi(
serializer: KSerializer<T>? = null,
noinline args: (JSONObject.() -> JSONObject)? = null,
): WalletResponse<T> = withContext(Dispatchers.Default) {
- suspendCoroutine { cont ->
- val json = Json {
- ignoreUnknownKeys = true
- }
- sendRequest(operation, args?.invoke(JSONObject())) { isError, message ->
- val response = try {
- if (isError) {
- val error =
- json.decodeFromString(TalerErrorInfo.serializer(), message.toString())
- WalletResponse.Error(error)
- } else {
- val t: T = serializer?.let {
- json.decodeFromString(serializer, message.toString())
- } ?: Unit as T
- WalletResponse.Success(t)
- }
- } catch (e: Exception) {
- val info = TalerErrorInfo(NONE, "", e.toString(), null)
- WalletResponse.Error(info)
+ val json = BackendManager.json
+ try {
+ when (val response = sendRequest(operation, args?.invoke(JSONObject()))) {
+ is ApiResponse.Response -> {
+ val t: T = serializer?.let {
+ json.decodeFromJsonElement(serializer, response.result)
+ } ?: Unit as T
+ WalletResponse.Success(t)
+ }
+
+ is ApiResponse.Error -> {
+ val error: TalerErrorInfo = json.decodeFromJsonElement(response.error)
+ WalletResponse.Error(error)
}
- cont.resume(response)
}
+ } catch (e: Exception) {
+ val info = TalerErrorInfo(NONE, "", e.toString())
+ WalletResponse.Error(info)
}
}
- fun destroy() {
- // FIXME: implement this!
- }
+ // Returns raw JSON response instead of serialized object
+ suspend inline fun rawRequest(
+ operation: String,
+ noinline args: (JSONObject.() -> JSONObject)? = null,
+ ): WalletResponse<JsonObject> = withContext(Dispatchers.Default) {
+ val json = BackendManager.json
+ try {
+ when (val response = sendRequest(operation, args?.invoke(JSONObject()))) {
+ is ApiResponse.Response -> {
+ WalletResponse.Success(response.result)
+ }
- companion object {
- const val TAG = "WalletBackendApi"
+ is ApiResponse.Error -> {
+ val error: TalerErrorInfo = json.decodeFromJsonElement(response.error)
+ WalletResponse.Error(error)
+ }
+ }
+ } catch (e: Exception) {
+ val info = TalerErrorInfo(NONE, "", e.toString())
+ WalletResponse.Error(info)
+ }
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendService.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendService.kt
deleted file mode 100644
index 06e0627..0000000
--- a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendService.kt
+++ /dev/null
@@ -1,254 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
- *
- * GNU Taler is free software; you can redistribute it and/or modify it under the
- * terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3, or (at your option) any later version.
- *
- * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-
-package net.taler.wallet.backend
-
-import android.app.Service
-import android.content.Intent
-import android.os.Handler
-import android.os.IBinder
-import android.os.Message
-import android.os.Messenger
-import android.os.RemoteException
-import android.util.Log
-import net.taler.akono.AkonoJni
-import net.taler.wallet.BuildConfig.WALLET_CORE_VERSION
-import net.taler.wallet.HostCardEmulatorService
-import org.json.JSONObject
-import java.lang.ref.WeakReference
-import java.util.LinkedList
-import java.util.concurrent.ConcurrentHashMap
-import kotlin.system.exitProcess
-
-private const val TAG = "taler-wallet-backend"
-const val WALLET_DB = "talerwalletdb-v30.json"
-
-class RequestData(val clientRequestId: Int, val messenger: Messenger)
-
-
-class WalletBackendService : Service() {
- /**
- * Target we publish for clients to send messages to IncomingHandler.
- */
- private val messenger: Messenger = Messenger(IncomingHandler(this))
-
- private lateinit var akono: AkonoJni
-
- private var initialized = false
-
- private var nextRequestID = 1
-
- private val requests = ConcurrentHashMap<Int, RequestData>()
-
- private val subscribers = LinkedList<Messenger>()
-
- override fun onCreate() {
- val talerWalletAndroidCode =
- assets.open("taler-wallet-embedded-$WALLET_CORE_VERSION.js").use {
- it.readBytes().toString(Charsets.UTF_8)
- }
-
-
- Log.i(TAG, "onCreate in wallet backend service")
- akono = AkonoJni()
- akono.putModuleCode("@gnu-taler/taler-wallet-embedded", talerWalletAndroidCode)
- akono.setMessageHandler(object : AkonoJni.MessageHandler {
- override fun handleMessage(message: String) {
- this@WalletBackendService.handleAkonoMessage(message)
- }
- })
- //akono.evalNodeCode("require('source-map-support').install();")
- akono.evalNodeCode("require('akono');")
- akono.evalNodeCode("tw = require('@gnu-taler/taler-wallet-embedded');")
- akono.evalNodeCode("tw.installNativeWalletListener();")
- sendInitMessage()
- super.onCreate()
- }
-
- private fun sendInitMessage() {
- val msg = JSONObject()
- msg.put("operation", "init")
- val args = JSONObject()
- msg.put("args", args)
- args.put("persistentStoragePath", "${application.filesDir}/$WALLET_DB")
- akono.sendMessage(msg.toString())
- }
-
- /**
- * Handler of incoming messages from clients.
- */
- class IncomingHandler(
- service: WalletBackendService,
- ) : Handler() {
-
- private val serviceWeakRef = WeakReference(service)
-
- override fun handleMessage(msg: Message) {
- val svc = serviceWeakRef.get() ?: return
- if (!svc.initialized) Log.w(TAG, "Warning: Not yet initialized")
- when (msg.what) {
- MSG_COMMAND -> {
- val data = msg.data
- val serviceRequestID = svc.nextRequestID++
- val clientRequestID = data.getInt("requestID", 0)
- if (clientRequestID == 0) {
- Log.e(TAG, "client requestID missing")
- return
- }
- val args = data.getString("args")
- val argsObj = if (args == null) {
- JSONObject()
- } else {
- JSONObject(args)
- }
- val operation = data.getString("operation", "")
- if (operation == "") {
- Log.e(TAG, "client command missing")
- return
- }
- Log.i(TAG, "got request for operation $operation")
- val request = JSONObject()
- request.put("operation", operation)
- request.put("id", serviceRequestID)
- request.put("args", argsObj)
- svc.akono.sendMessage(request.toString(2))
- Log.i(
- TAG,
- "mapping service request ID $serviceRequestID to client request ID $clientRequestID"
- )
- svc.requests[serviceRequestID] = RequestData(clientRequestID, msg.replyTo)
- }
- MSG_SUBSCRIBE_NOTIFY -> {
- Log.i(TAG, "subscribing client")
- val r = msg.replyTo
- if (r == null) {
- Log.e(
- TAG,
- "subscriber did not specify replyTo object in MSG_SUBSCRIBE_NOTIFY"
- )
- } else {
- svc.subscribers.add(msg.replyTo)
- }
- }
- MSG_UNSUBSCRIBE_NOTIFY -> {
- Log.i(TAG, "unsubscribing client")
- svc.subscribers.remove(msg.replyTo)
- }
- else -> {
- Log.e(TAG, "unknown message from client")
- super.handleMessage(msg)
- }
- }
- }
- }
-
- override fun onBind(p0: Intent?): IBinder? {
- return messenger.binder
- }
-
- private fun sendNotify(payload: String) {
- var rm: LinkedList<Messenger>? = null
- for (s in subscribers) {
- val m = Message.obtain(null, MSG_NOTIFY)
- val b = m.data
- b.putString("payload", payload)
- try {
- s.send(m)
- } catch (e: RemoteException) {
- if (rm == null) {
- rm = LinkedList()
- }
- rm.add(s)
- subscribers.remove(s)
- }
- }
- if (rm != null) {
- for (s in rm) {
- subscribers.remove(s)
- }
- }
- }
-
- private fun handleAkonoMessage(messageStr: String) {
- val message = JSONObject(messageStr)
- when (val type = message.getString("type")) {
- "notification" -> {
- val payload = message.getJSONObject("payload")
- if (payload.optString("type") != "waiting-for-retry") {
- Log.v(TAG, "got back notification: ${message.toString(2)}")
- }
- sendNotify(payload.toString())
- }
- "tunnelHttp" -> {
- Log.v(TAG, "got http tunnel request! ${message.toString(2)}")
- Intent().also { intent ->
- intent.action = HostCardEmulatorService.HTTP_TUNNEL_REQUEST
- intent.putExtra("tunnelMessage", messageStr)
- application.sendBroadcast(intent)
- }
- }
- "response" -> {
- when (message.getString("operation")) {
- "init" -> {
- Log.d(TAG, "got response for init operation: ${message.toString(2)}")
- initialized = true
- sendNotify(message.toString(2))
- }
- "reset" -> {
- Log.v(TAG, "got back message: ${message.toString(2)}")
- exitProcess(1)
- }
- else -> {
- Log.v(TAG, "got back response: ${message.toString(2)}")
- val payload = message.getJSONObject("result").toString(2)
- handleResponse(false, message, payload)
- }
- }
- }
- "error" -> {
- Log.v(TAG, "got back error: ${message.toString(2)}")
- val payload = message.getJSONObject("error").toString(2)
- handleResponse(true, message, payload)
- }
- else -> throw IllegalArgumentException("Unknown message type: $type")
- }
- }
-
- private fun handleResponse(isError: Boolean, message: JSONObject, payload: String) {
- val id = message.getInt("id")
- val rId = requests[id]
- if (rId == null) {
- Log.e(TAG, "wallet returned unknown request ID ($id)")
- return
- }
- val m = Message.obtain(null, MSG_REPLY)
- val b = m.data
- b.putInt("requestID", rId.clientRequestId)
- b.putBoolean("isError", isError)
- b.putString("response", payload)
- b.putString("operation", message.getString("operation"))
- rId.messenger.send(m)
- }
-
- companion object {
- const val MSG_SUBSCRIBE_NOTIFY = 1
- const val MSG_UNSUBSCRIBE_NOTIFY = 2
- const val MSG_COMMAND = 3
- const val MSG_REPLY = 4
- const val MSG_NOTIFY = 5
- }
-}
diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt
index 414d784..3946457 100644
--- a/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt
+++ b/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt
@@ -19,13 +19,18 @@ package net.taler.wallet.backend
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-import kotlinx.serialization.descriptors.PrimitiveKind.STRING
-import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.builtins.MapSerializer
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
-import org.json.JSONObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
@Serializable
sealed class WalletResponse<T> {
@@ -52,7 +57,7 @@ sealed class WalletResponse<T> {
}
}
-@Serializable
+@Serializable(with = TalerErrorInfoSerializer::class)
data class TalerErrorInfo(
// Numeric error code defined defined in the
// GANA gnu-taler-error-codes registry.
@@ -65,42 +70,65 @@ data class TalerErrorInfo(
// for the instance of the error.
val message: String? = null,
- // Error details
- @Serializable(JSONObjectDeserializer::class)
- val details: JSONObject? = null,
-
- // KYC URL (in case KYC is required)
- val kycUrl: String? = null,
+ // Error extra details
+ val extra: Map<String, JsonElement> = mapOf(),
) {
val userFacingMsg: String
get() {
return StringBuilder().apply {
- hint?.let { append(it) }
- message?.let { append(" ").append(it) }
- details?.let { details ->
- if (details.length() > 0) {
- append("\n\n")
- details.optJSONObject("errorResponse")?.let { errorResponse ->
- append(errorResponse.optString("code")).append(" ")
- append(errorResponse.optString("hint"))
- } ?: append(details.toString(2))
+ // If there's a hint in errorResponse, use it.
+ if (extra.containsKey("errorResponse")) {
+ val errorResponse = extra["errorResponse"]!!.jsonObject
+ if (errorResponse.containsKey("hint")) {
+ val hint = errorResponse["hint"]!!.jsonPrimitive.content
+ append(hint)
}
+ } else {
+ // Otherwise, use the standard ones
+ hint?.let { append(it) }
+ message?.let { append(" ").append(it) }
}
}.toString()
}
+
+ fun getStringExtra(key: String): String? =
+ extra[key]?.jsonPrimitive?.content
}
-class JSONObjectDeserializer : KSerializer<JSONObject> {
+class TalerErrorInfoSerializer : KSerializer<TalerErrorInfo> {
+ private val stringToJsonElementSerializer = MapSerializer(String.serializer(), JsonElement.serializer())
+
+ override val descriptor: SerialDescriptor
+ get() = stringToJsonElementSerializer.descriptor
+
+ override fun deserialize(decoder: Decoder): TalerErrorInfo {
+ // Decoder -> JsonInput
+ require(decoder is JsonDecoder)
+ val json = decoder.json
+ val filtersMap = decoder.decodeSerializableValue(stringToJsonElementSerializer)
+
+ val code = filtersMap["code"]?.let {
+ json.decodeFromJsonElement(TalerErrorCode.serializer(), it)
+ } ?: TalerErrorCode.UNKNOWN
+ val hint = filtersMap["hint"]?.let {
+ json.decodeFromJsonElement(String.serializer(), it)
+ }
+ val message = filtersMap["message"]?.let {
+ json.decodeFromJsonElement(String.serializer(), it)
+ }
- override val descriptor = PrimitiveSerialDescriptor("JSONObjectDeserializer", STRING)
+ val knownKeys = setOf("code", "hint", "message")
+ val unknownFilters = filtersMap.filter { (key, _) -> !knownKeys.contains(key) }
- override fun deserialize(decoder: Decoder): JSONObject {
- val input = decoder as JsonDecoder
- val tree = input.decodeJsonElement() as JsonObject
- return JSONObject(tree.toString())
+ return TalerErrorInfo(code, hint, message, unknownFilters)
}
- override fun serialize(encoder: Encoder, value: JSONObject) {
- error("not supported")
+ override fun serialize(encoder: Encoder, value: TalerErrorInfo) {
+ encoder.encodeSerializableValue(JsonObject.serializer(), buildJsonObject {
+ put("code", JsonPrimitive(value.code.code))
+ put("hint", JsonPrimitive(value.hint))
+ put("message", JsonPrimitive(value.message))
+ value.extra.forEach { (key, value) -> put(key, value) }
+ })
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt
index 24ee1a1..aabef4b 100644
--- a/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt
+++ b/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt
@@ -24,20 +24,12 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter
-import kotlinx.serialization.Serializable
-import net.taler.common.Amount
import net.taler.wallet.R
import net.taler.wallet.balances.BalanceAdapter.BalanceViewHolder
-
-@Serializable
-data class BalanceItem(
- val available: Amount,
- val pendingIncoming: Amount,
- val pendingOutgoing: Amount
-) {
- val currency: String get() = available.currency
- val hasPending: Boolean get() = !pendingIncoming.isZero() || !pendingOutgoing.isZero()
-}
+import net.taler.wallet.balances.ScopeInfo.Auditor
+import net.taler.wallet.balances.ScopeInfo.Exchange
+import net.taler.wallet.balances.ScopeInfo.Global
+import net.taler.wallet.cleanExchange
class BalanceAdapter(private val listener: BalanceClickListener) : Adapter<BalanceViewHolder>() {
@@ -66,28 +58,43 @@ class BalanceAdapter(private val listener: BalanceClickListener) : Adapter<Balan
}
inner class BalanceViewHolder(private val v: View) : RecyclerView.ViewHolder(v) {
- private val currencyView: TextView = v.findViewById(R.id.balanceCurrencyView)
private val amountView: TextView = v.findViewById(R.id.balanceAmountView)
+ private val scopeView: TextView = v.findViewById(R.id.scopeView)
private val balanceInboundAmount: TextView = v.findViewById(R.id.balanceInboundAmount)
- private val balanceInboundLabel: TextView = v.findViewById(R.id.balanceInboundLabel)
- private val pendingView: TextView = v.findViewById(R.id.pendingView)
+ private val balanceOutboundAmount: TextView = v.findViewById(R.id.balanceOutboundAmount)
fun bind(item: BalanceItem) {
- v.setOnClickListener { listener.onBalanceClick(item.available.currency) }
- currencyView.text = item.currency
- amountView.text = item.available.amountStr
+ v.setOnClickListener { listener.onBalanceClick(item.scopeInfo) }
+ amountView.text = item.available.toString()
val amountIncoming = item.pendingIncoming
if (amountIncoming.isZero()) {
balanceInboundAmount.visibility = GONE
- balanceInboundLabel.visibility = GONE
} else {
balanceInboundAmount.visibility = VISIBLE
- balanceInboundLabel.visibility = VISIBLE
- balanceInboundAmount.text =
- v.context.getString(R.string.amount_positive, amountIncoming)
+ balanceInboundAmount.text = v.context.getString(R.string.balances_inbound_amount, amountIncoming.toString(showSymbol = false))
+ }
+
+ val amountOutgoing = item.pendingOutgoing
+ if (amountOutgoing.isZero()) {
+ balanceOutboundAmount.visibility = GONE
+ } else {
+ balanceOutboundAmount.visibility = VISIBLE
+ balanceOutboundAmount.text = v.context.getString(R.string.balances_outbound_amount, amountOutgoing.toString(showSymbol = false))
+ }
+
+ val scopeInfo = item.scopeInfo
+ scopeView.visibility = when (scopeInfo) {
+ is Global -> GONE
+ is Exchange -> {
+ scopeView.text = v.context.getString(R.string.balance_scope_exchange, cleanExchange(scopeInfo.url))
+ VISIBLE
+ }
+ is Auditor -> {
+ scopeView.text = v.context.getString(R.string.balance_scope_auditor, cleanExchange(scopeInfo.url))
+ VISIBLE
+ }
}
- pendingView.visibility = if (item.hasPending) VISIBLE else GONE
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt
new file mode 100644
index 0000000..42e67cf
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt
@@ -0,0 +1,137 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.balances
+
+import android.util.Log
+import androidx.annotation.UiThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.distinctUntilChanged
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import net.taler.common.CurrencySpecification
+import net.taler.wallet.TAG
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.backend.WalletBackendApi
+import org.json.JSONObject
+
+@Serializable
+data class BalanceResponse(
+ val balances: List<BalanceItem>
+)
+
+@Serializable
+data class GetCurrencySpecificationResponse(
+ val currencySpecification: CurrencySpecification,
+)
+
+sealed class BalanceState {
+ data object None: BalanceState()
+ data object Loading: BalanceState()
+
+ data class Success(
+ val balances: List<BalanceItem>,
+ ): BalanceState()
+
+ data class Error(
+ val error: TalerErrorInfo,
+ ): BalanceState()
+}
+
+class BalanceManager(
+ private val api: WalletBackendApi,
+ private val scope: CoroutineScope,
+) {
+ private val mBalances = MutableLiveData<List<BalanceItem>>(emptyList())
+ val balances: LiveData<List<BalanceItem>> = mBalances
+
+ private val mState = MutableLiveData<BalanceState>(BalanceState.None)
+ val state: LiveData<BalanceState> = mState.distinctUntilChanged()
+
+ private val currencySpecs: MutableMap<ScopeInfo, CurrencySpecification?> = mutableMapOf()
+
+ @UiThread
+ fun loadBalances() {
+ mState.value = BalanceState.Loading
+ scope.launch {
+ val response = api.request("getBalances", BalanceResponse.serializer())
+ response.onError {
+ Log.e(TAG, "Error retrieving balances: $it")
+ mState.postValue(BalanceState.Error(it))
+ }
+ response.onSuccess {
+ mBalances.postValue(it.balances)
+ scope.launch {
+ // Fetch missing currency specs for all balances
+ it.balances.forEach { balance ->
+ if (!currencySpecs.containsKey(balance.scopeInfo)) {
+ currencySpecs[balance.scopeInfo] = getCurrencySpecification(balance.scopeInfo)
+ }
+ }
+
+ mState.postValue(
+ BalanceState.Success(it.balances.map { balance ->
+ val spec = currencySpecs[balance.scopeInfo]
+ balance.copy(
+ available = balance.available.withSpec(spec),
+ pendingIncoming = balance.pendingIncoming.withSpec(spec),
+ pendingOutgoing = balance.pendingOutgoing.withSpec(spec),
+ )
+ }),
+ )
+ }
+ }
+ }
+ }
+
+ private suspend fun getCurrencySpecification(scopeInfo: ScopeInfo): CurrencySpecification? {
+ var spec: CurrencySpecification? = null
+ api.request("getCurrencySpecification", GetCurrencySpecificationResponse.serializer()) {
+ val json = Json.encodeToString(scopeInfo)
+ Log.d(TAG, "BalanceManager: $json")
+ put("scope", JSONObject(json))
+ }.onSuccess {
+ 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? {
+ val state = mState.value
+ if (state !is BalanceState.Success) return null
+
+ return state.balances.find { it.currency == currency }?.available?.spec
+ }
+
+ 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
+ }
+
+ fun resetBalances() {
+ mState.value = BalanceState.None
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/balances/Balances.kt b/wallet/src/main/java/net/taler/wallet/balances/Balances.kt
new file mode 100644
index 0000000..dff2ffb
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/balances/Balances.kt
@@ -0,0 +1,57 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.balances
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.taler.common.Amount
+
+@Serializable
+data class BalanceItem(
+ val scopeInfo: ScopeInfo,
+ val available: Amount,
+ val pendingIncoming: Amount,
+ val pendingOutgoing: Amount,
+) {
+ val currency: String get() = available.currency
+ val hasPending: Boolean get() = !pendingIncoming.isZero() || !pendingOutgoing.isZero()
+}
+
+@Serializable
+sealed class ScopeInfo {
+ abstract val currency: String
+
+ @Serializable
+ @SerialName("global")
+ data class Global(
+ override val currency: String
+ ): ScopeInfo()
+
+ @Serializable
+ @SerialName("exchange")
+ data class Exchange(
+ override val currency: String,
+ val url: String,
+ ): ScopeInfo()
+
+ @Serializable
+ @SerialName("auditor")
+ data class Auditor(
+ override val currency: String,
+ val url: String,
+ ): ScopeInfo()
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt
index c1be674..93636ea 100644
--- a/wallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt
@@ -29,11 +29,17 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
import net.taler.common.fadeIn
+import net.taler.common.showError
import net.taler.wallet.MainViewModel
+import net.taler.wallet.balances.BalanceState.Error
+import net.taler.wallet.balances.BalanceState.Loading
+import net.taler.wallet.balances.BalanceState.None
+import net.taler.wallet.balances.BalanceState.Success
import net.taler.wallet.databinding.FragmentBalancesBinding
+import net.taler.wallet.showError
interface BalanceClickListener {
- fun onBalanceClick(currency: String)
+ fun onBalanceClick(scopeInfo: ScopeInfo)
}
class BalancesFragment : Fragment(),
@@ -59,25 +65,39 @@ class BalancesFragment : Fragment(),
addItemDecoration(DividerItemDecoration(context, VERTICAL))
}
- model.balances.observe(viewLifecycleOwner) {
+ model.balanceManager.state.observe(viewLifecycleOwner) {
onBalancesChanged(it)
}
}
- private fun onBalancesChanged(balances: List<BalanceItem>) {
- beginDelayedTransition(view as ViewGroup)
- if (balances.isEmpty()) {
- ui.mainEmptyState.visibility = VISIBLE
- ui.mainList.visibility = GONE
- } else {
- balancesAdapter.setItems(balances)
- ui.mainEmptyState.visibility = INVISIBLE
- ui.mainList.fadeIn()
+ private fun onBalancesChanged(state: BalanceState) {
+ model.showProgressBar.value = false
+ when (state) {
+ is None -> {}
+ is Loading -> {
+ model.showProgressBar.value = true
+ }
+ is Success -> {
+ beginDelayedTransition(view as ViewGroup)
+ if (state.balances.isEmpty()) {
+ ui.mainEmptyState.visibility = VISIBLE
+ ui.mainList.visibility = GONE
+ } else {
+ balancesAdapter.setItems(state.balances)
+ ui.mainEmptyState.visibility = INVISIBLE
+ ui.mainList.fadeIn()
+ }
+ }
+ is Error -> if (model.devMode.value == true) {
+ showError(state.error)
+ } else {
+ showError(state.error.userFacingMsg)
+ }
}
}
- override fun onBalanceClick(currency: String) {
- model.showTransactions(currency)
+ override fun onBalanceClick(scopeInfo: ScopeInfo) {
+ model.showTransactions(scopeInfo)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt
new file mode 100644
index 0000000..d2a877b
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt
@@ -0,0 +1,228 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import android.os.Build
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+import net.taler.common.Amount
+import java.text.DecimalFormat
+import java.text.DecimalFormatSymbols
+import kotlin.math.max
+import kotlin.math.pow
+import kotlin.math.roundToLong
+
+const val DEFAULT_INPUT_DECIMALS = 2
+
+@Composable
+fun AmountInputField(
+ value: String,
+ onValueChange: (value: String) -> Unit,
+ modifier: Modifier = Modifier,
+ label: @Composable (() -> Unit)? = null,
+ supportingText: @Composable (() -> Unit)? = null,
+ isError: Boolean = false,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ decimalFormatSymbols: DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols,
+ numberOfDecimals: Int = DEFAULT_INPUT_DECIMALS,
+ readOnly: Boolean = false,
+) {
+ var amountInput by remember { mutableStateOf(value) }
+
+ // React to external changes
+ val amountValue = remember(amountInput, value) {
+ transformOutput(amountInput).let {
+ if (value != it) transformInput(value, numberOfDecimals) else amountInput
+ }
+ }
+
+ OutlinedTextField(
+ value = amountValue,
+ onValueChange = { input ->
+ if (input.matches("0+".toRegex())) {
+ amountInput = "0"
+ onValueChange("")
+ } else transformOutput(input, numberOfDecimals)?.let { filtered ->
+ if (Amount.isValidAmountStr(filtered) && !input.contains("-")) {
+ amountInput = input.trimStart('0')
+ onValueChange(filtered)
+ }
+ }
+ },
+ modifier = modifier,
+ readOnly = readOnly,
+ textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace),
+ label = label,
+ supportingText = supportingText,
+ isError = isError,
+ visualTransformation = AmountInputVisualTransformation(
+ symbols = decimalFormatSymbols,
+ fixedCursorAtTheEnd = true,
+ numberOfDecimals = numberOfDecimals,
+ ),
+ keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.NumberPassword),
+ keyboardActions = keyboardActions,
+ singleLine = true,
+ maxLines = 1,
+ )
+}
+
+// 500 -> 5.0
+private fun transformOutput(
+ input: String,
+ numberOfDecimals: Int = 2,
+) = if (input.isEmpty()) "0" else {
+ input.toLongOrNull()?.let { it / 10.0.pow(numberOfDecimals) }?.toBigDecimal()?.toPlainString()
+}
+
+// 5.0 -> 500
+private fun transformInput(
+ output: String,
+ numberOfDecimals: Int = 2,
+) = if (output.isEmpty()) "0" else {
+ (output.toDouble() * 10.0.pow(numberOfDecimals)).roundToLong().toString()
+}
+
+// Source: https://github.com/banmarkovic/CurrencyAmountInput
+
+private class AmountInputVisualTransformation(
+ private val symbols: DecimalFormatSymbols,
+ private val fixedCursorAtTheEnd: Boolean = true,
+ private val numberOfDecimals: Int = 2,
+): VisualTransformation {
+
+ override fun filter(text: AnnotatedString): TransformedText {
+ val thousandsSeparator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ symbols.monetaryGroupingSeparator
+ } else {
+ symbols.groupingSeparator
+ }
+ val decimalSeparator = symbols.monetaryDecimalSeparator
+ val zero = symbols.zeroDigit
+
+ val inputText = text.text
+
+ val intPart = inputText
+ .dropLast(numberOfDecimals)
+ .reversed()
+ .chunked(3)
+ .joinToString(thousandsSeparator.toString())
+ .reversed()
+ .ifEmpty {
+ zero.toString()
+ }
+
+ val fractionPart = inputText.takeLast(numberOfDecimals).let {
+ if (it.length != numberOfDecimals) {
+ List(numberOfDecimals - it.length) {
+ zero
+ }.joinToString("") + it
+ } else {
+ it
+ }
+ }
+
+ // Hide trailing decimal separator if decimals are 0
+ val formattedNumber = if (numberOfDecimals > 0) {
+ intPart + decimalSeparator + fractionPart
+ } else {
+ intPart
+ }
+
+ val newText = AnnotatedString(
+ text = formattedNumber,
+ spanStyles = text.spanStyles,
+ paragraphStyles = text.paragraphStyles
+ )
+
+ val offsetMapping = if (fixedCursorAtTheEnd) {
+ FixedCursorOffsetMapping(
+ contentLength = inputText.length,
+ formattedContentLength = formattedNumber.length
+ )
+ } else {
+ MovableCursorOffsetMapping(
+ unmaskedText = text.toString(),
+ maskedText = newText.toString(),
+ decimalDigits = numberOfDecimals
+ )
+ }
+
+ return TransformedText(newText, offsetMapping)
+ }
+
+ private class FixedCursorOffsetMapping(
+ private val contentLength: Int,
+ private val formattedContentLength: Int,
+ ) : OffsetMapping {
+ override fun originalToTransformed(offset: Int): Int = formattedContentLength
+ override fun transformedToOriginal(offset: Int): Int = contentLength
+ }
+
+ private class MovableCursorOffsetMapping(
+ private val unmaskedText: String,
+ private val maskedText: String,
+ private val decimalDigits: Int
+ ) : OffsetMapping {
+ override fun originalToTransformed(offset: Int): Int =
+ when {
+ unmaskedText.length <= decimalDigits -> {
+ maskedText.length - (unmaskedText.length - offset)
+ }
+ else -> {
+ offset + offsetMaskCount(offset, maskedText)
+ }
+ }
+
+ override fun transformedToOriginal(offset: Int): Int =
+ when {
+ unmaskedText.length <= decimalDigits -> {
+ max(unmaskedText.length - (maskedText.length - offset), 0)
+ }
+ else -> {
+ offset - maskedText.take(offset).count { !it.isDigit() }
+ }
+ }
+
+ private fun offsetMaskCount(offset: Int, maskedText: String): Int {
+ var maskOffsetCount = 0
+ var dataCount = 0
+ for (maskChar in maskedText) {
+ if (!maskChar.isDigit()) {
+ maskOffsetCount++
+ } else if (++dataCount > offset) {
+ break
+ }
+ }
+ return maskOffsetCount
+ }
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/settings/BackupSettingsFragment.kt b/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt
index e9641a3..6412d63 100644
--- a/wallet/src/main/java/net/taler/wallet/settings/BackupSettingsFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt
@@ -1,6 +1,6 @@
/*
* This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
+ * (C) 2024 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
@@ -14,16 +14,21 @@
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-package net.taler.wallet.settings
+package net.taler.wallet.compose
-import android.os.Bundle
-import androidx.preference.PreferenceFragmentCompat
-import net.taler.wallet.R
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
-class BackupSettingsFragment : PreferenceFragmentCompat() {
-
- override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
- setPreferencesFromResource(R.xml.settings_backup, rootKey)
+@Composable
+fun LoadingScreen() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
}
-
-}
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt b/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt
new file mode 100644
index 0000000..47401cf
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt
@@ -0,0 +1,70 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun NumericInputField(
+ modifier: Modifier = Modifier,
+ value: Long,
+ onValueChange: (Long) -> Unit,
+ readOnly: Boolean = true,
+ label: @Composable () -> Unit,
+ minValue: Long? = 0L,
+ maxValue: Long? = null,
+) {
+ OutlinedTextField(
+ modifier = modifier,
+ value = value.toString(),
+ singleLine = true,
+ readOnly = readOnly,
+ onValueChange = {
+ val dd = it.toLongOrNull() ?: 0
+ onValueChange(dd)
+ },
+ trailingIcon = {
+ Row {
+ IconButton(
+ content = { Icon(Icons.Default.Remove, "add1") },
+ onClick = {
+ if (minValue != null && value - 1 >= minValue) {
+ onValueChange(value - 1)
+ }
+ },
+ )
+ IconButton(
+ content = { Icon(Icons.Default.Add, "add1") },
+ onClick = {
+ if (maxValue != null && value + 1 <= maxValue) {
+ onValueChange(value + 1)
+ }
+ },
+ )
+ }
+ },
+ label = label,
+ )
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt
index 5359f1a..4991094 100644
--- a/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt
+++ b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt
@@ -25,24 +25,23 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.material.Button
-import androidx.compose.material.ButtonColors
-import androidx.compose.material.ButtonDefaults
-import androidx.compose.material.Icon
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -86,7 +85,7 @@ fun ColumnScope.QrCodeUriComposable(
Text(
modifier = Modifier.horizontalScroll(scrollState),
fontFamily = FontFamily.Monospace,
- style = MaterialTheme.typography.body1,
+ style = MaterialTheme.typography.bodyLarge,
text = talerUri,
)
}
@@ -100,11 +99,17 @@ fun ColumnScope.QrCodeUriComposable(
label = clipBoardLabel,
content = talerUri,
buttonText = buttonText,
- colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
)
ShareButton(
content = talerUri,
- colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
)
}
}
@@ -131,14 +136,13 @@ fun CopyToClipboardButton(
colors = colors,
onClick = { copyToClipBoard(context, label, content) },
) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Default.ContentCopy, buttonText)
- Text(
- modifier = Modifier.padding(start = 8.dp),
- text = buttonText,
- style = MaterialTheme.typography.body1,
- )
- }
+ Icon(
+ Icons.Default.ContentCopy,
+ buttonText,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(buttonText)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt b/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt
new file mode 100644
index 0000000..c47f55d
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt
@@ -0,0 +1,46 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SuggestionChip
+import androidx.compose.material3.SuggestionChipDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+
+@Composable
+fun <T> SelectionChip(
+ label: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ selected: Boolean,
+ value: T,
+ onSelected: (T) -> Unit,
+) {
+ val theme = MaterialTheme.colorScheme
+ SuggestionChip(
+ label = label,
+ modifier = modifier,
+ onClick = {
+ onSelected(value)
+ },
+ colors = SuggestionChipDefaults.suggestionChipColors(
+ containerColor = if (selected) theme.primaryContainer else Color.Transparent,
+ labelColor = if (selected) theme.onPrimaryContainer else theme.onSurface
+ )
+ )
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt b/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt
index 0ac7048..f3a84dd 100644
--- a/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt
+++ b/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt
@@ -19,22 +19,19 @@ package net.taler.wallet.compose
import android.content.Intent
import android.content.Intent.ACTION_SEND
import android.content.Intent.EXTRA_TEXT
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.Button
-import androidx.compose.material.ButtonColors
-import androidx.compose.material.ButtonDefaults
-import androidx.compose.material.Icon
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import net.taler.wallet.R
@@ -59,13 +56,12 @@ fun ShareButton(
startActivity(context, shareIntent, null)
},
) {
- Row(verticalAlignment = CenterVertically) {
- Icon(Icons.Default.Share, buttonText)
- Text(
- modifier = Modifier.padding(start = 8.dp),
- text = buttonText,
- style = MaterialTheme.typography.body1,
- )
- }
+ Icon(
+ Icons.Default.Share,
+ buttonText,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(buttonText)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/compose/Utils.kt b/wallet/src/main/java/net/taler/wallet/compose/Utils.kt
index 8e3a032..9a27431 100644
--- a/wallet/src/main/java/net/taler/wallet/compose/Utils.kt
+++ b/wallet/src/main/java/net/taler/wallet/compose/Utils.kt
@@ -16,7 +16,7 @@
package net.taler.wallet.compose
-import androidx.compose.material.Surface
+import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
@@ -25,7 +25,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.flowWithLifecycle
-import com.google.accompanist.themeadapter.material.MdcTheme
+import com.google.accompanist.themeadapter.material3.Mdc3Theme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlin.coroutines.CoroutineContext
@@ -56,7 +56,7 @@ fun <T> StateFlow<T>.collectAsStateLifecycleAware(
@Composable
fun TalerSurface(content: @Composable () -> Unit) {
- MdcTheme {
+ Mdc3Theme {
Surface {
content()
}
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt
index c4b302f..20acee1 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt
@@ -32,10 +32,13 @@ import net.taler.wallet.MainViewModel
import net.taler.wallet.R
import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.compose.collectAsStateLifecycleAware
+import net.taler.wallet.showError
class DepositFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
private val depositManager get() = model.depositManager
+ private val balanceManager get() = model.balanceManager
+ private val transactionManager get() = model.transactionManager
override fun onCreateView(
inflater: LayoutInflater,
@@ -45,12 +48,12 @@ class DepositFragment : Fragment() {
val amount = arguments?.getString("amount")?.let {
Amount.fromJSONString(it)
} ?: error("no amount passed")
+ val scopeInfo = transactionManager.selectedScope
+ val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) }
val receiverName = arguments?.getString("receiverName")
val iban = arguments?.getString("IBAN")
- val bic = arguments?.getString("BIC") ?: ""
-
if (receiverName != null && iban != null) {
- onDepositButtonClicked(amount, receiverName, iban, bic)
+ onDepositButtonClicked(amount, receiverName, iban)
}
return ComposeView(requireContext()).apply {
setContent {
@@ -58,14 +61,14 @@ class DepositFragment : Fragment() {
val state = depositManager.depositState.collectAsStateLifecycleAware()
if (amount.currency == CURRENCY_BTC) MakeBitcoinDepositComposable(
state = state.value,
- amount = amount,
+ amount = amount.withSpec(spec),
bitcoinAddress = null,
onMakeDeposit = { amount, bitcoinAddress ->
depositManager.onDepositButtonClicked(amount, bitcoinAddress)
},
) else MakeDepositComposable(
state = state.value,
- amount = amount,
+ amount = amount.withSpec(spec),
presetName = receiverName,
presetIban = iban,
onMakeDeposit = this@DepositFragment::onDepositButtonClicked,
@@ -80,7 +83,11 @@ class DepositFragment : Fragment() {
lifecycleScope.launchWhenStarted {
depositManager.depositState.collect { state ->
if (state is DepositState.Error) {
- showError(state.msg)
+ if (model.devMode.value == false) {
+ showError(state.error.userFacingMsg)
+ } else {
+ showError(state.error)
+ }
} else if (state is DepositState.Success) {
findNavController().navigate(R.id.action_nav_deposit_to_nav_main)
}
@@ -104,8 +111,7 @@ class DepositFragment : Fragment() {
amount: Amount,
receiverName: String,
iban: String,
- bic: String,
) {
- depositManager.onDepositButtonClicked(amount, receiverName, iban, bic)
+ depositManager.onDepositButtonClicked(amount, receiverName, iban)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt
index 0f10b5f..0075f95 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt
@@ -46,10 +46,36 @@ class DepositManager(
}
@UiThread
- fun onDepositButtonClicked(amount: Amount, receiverName: String, iban: String, bic: String) {
+ fun onDepositButtonClicked(amount: Amount, receiverName: String, iban: String) {
+ if (depositState.value is DepositState.FeesChecked) {
+ // fees already checked, so IBAN was validated, can make deposit directly
+ makeIbanDeposit(amount, receiverName, iban)
+ } else {
+ // validate IBAN first
+ mDepositState.value = DepositState.CheckingFees
+ scope.launch {
+ api.request("validateIban", ValidateIbanResponse.serializer()) {
+ put("iban", iban)
+ }.onError {
+ Log.e(TAG, "Error validateIban $it")
+ mDepositState.value = DepositState.Error(it)
+ }.onSuccess { response ->
+ if (response.valid) {
+ // only prepare/make deposit, if IBAN is valid
+ makeIbanDeposit(amount, receiverName, iban)
+ } else {
+ mDepositState.value = DepositState.IbanInvalid
+ }
+ }
+ }
+ }
+ }
+
+ @UiThread
+ private fun makeIbanDeposit(amount: Amount, receiverName: String, iban: String) {
val paytoUri: String = PaytoUriIban(
iban = iban,
- bic = bic,
+ bic = null,
targetPath = "",
params = mapOf("receiver-name" to receiverName),
).paytoUri
@@ -66,7 +92,7 @@ class DepositManager(
}
private fun makeDeposit(amount: Amount, uri: String) {
- if (depositState.value.showFees) makeDeposit(
+ if (depositState.value is DepositState.FeesChecked) makeDeposit(
paytoUri = uri,
amount = amount,
totalDepositCost = depositState.value.totalDepositCost
@@ -86,7 +112,7 @@ class DepositManager(
put("amount", amount.toJSONString())
}.onError {
Log.e(TAG, "Error prepareDeposit $it")
- mDepositState.value = DepositState.Error(it.userFacingMsg)
+ mDepositState.value = DepositState.Error(it)
}.onSuccess {
mDepositState.value = DepositState.FeesChecked(
totalDepositCost = it.totalDepositCost,
@@ -112,7 +138,7 @@ class DepositManager(
put("amount", amount.toJSONString())
}.onError {
Log.e(TAG, "Error createDepositGroup $it")
- mDepositState.value = DepositState.Error(it.userFacingMsg)
+ mDepositState.value = DepositState.Error(it)
}.onSuccess {
mDepositState.value = DepositState.Success
}
@@ -126,6 +152,11 @@ class DepositManager(
}
@Serializable
+data class ValidateIbanResponse(
+ val valid: Boolean,
+)
+
+@Serializable
data class PrepareDepositResponse(
val totalDepositCost: Amount,
val effectiveDepositAmount: Amount,
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt
index 918d74c..168378f 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt
@@ -17,6 +17,7 @@
package net.taler.wallet.deposit
import net.taler.common.Amount
+import net.taler.wallet.backend.TalerErrorInfo
sealed class DepositState {
@@ -26,6 +27,7 @@ sealed class DepositState {
object Start : DepositState()
object CheckingFees : DepositState()
+ object IbanInvalid : DepositState()
class FeesChecked(
override val totalDepositCost: Amount,
override val effectiveDepositAmount: Amount,
@@ -42,6 +44,6 @@ sealed class DepositState {
object Success : DepositState()
- class Error(val msg: String) : DepositState()
+ class Error(val error: TalerErrorInfo) : DepositState()
}
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeBitcoinDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeBitcoinDepositComposable.kt
index 1ff3423..d356051 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/MakeBitcoinDepositComposable.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/MakeBitcoinDepositComposable.kt
@@ -22,10 +22,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.OutlinedTextField
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -39,7 +40,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -71,6 +71,7 @@ fun MakeBitcoinDepositComposable(
.padding(16.dp)
.focusRequester(focusRequester),
value = address,
+ singleLine = true,
enabled = !state.showFees,
onValueChange = { input ->
address = input
@@ -80,7 +81,7 @@ fun MakeBitcoinDepositComposable(
Text(
stringResource(R.string.send_deposit_bitcoin_address),
color = if (address.isBlank()) {
- colorResource(R.color.red)
+ MaterialTheme.colorScheme.error
} else Color.Unspecified,
)
}
@@ -90,7 +91,7 @@ fun MakeBitcoinDepositComposable(
}
val amountTitle = if (state.effectiveDepositAmount == null) {
R.string.amount_chosen
- } else R.string.send_deposit_amount_effective
+ } else R.string.amount_effective
TransactionAmountComposable(
label = stringResource(id = amountTitle),
amount = state.effectiveDepositAmount ?: amount,
@@ -103,14 +104,16 @@ fun MakeBitcoinDepositComposable(
) {
val totalAmount = state.totalDepositCost ?: amount
val effectiveAmount = state.effectiveDepositAmount ?: Amount.zero(amount.currency)
- val fee = totalAmount - effectiveAmount
+ if (totalAmount > effectiveAmount) {
+ val fee = totalAmount - effectiveAmount
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee,
+ amountType = AmountType.Negative,
+ )
+ }
TransactionAmountComposable(
- label = stringResource(id = R.string.withdraw_fees),
- amount = fee,
- amountType = AmountType.Negative,
- )
- TransactionAmountComposable(
- label = stringResource(id = R.string.send_amount),
+ label = stringResource(id = R.string.amount_send),
amount = totalAmount,
amountType = AmountType.Positive,
)
@@ -120,8 +123,8 @@ fun MakeBitcoinDepositComposable(
Text(
modifier = Modifier.padding(16.dp),
fontSize = 18.sp,
- color = colorResource(R.color.red),
- text = (state as? DepositState.Error)?.msg ?: "",
+ color = MaterialTheme.colorScheme.error,
+ text = (state as? DepositState.Error)?.error?.userFacingMsg ?: "",
)
}
val focusManager = LocalFocusManager.current
@@ -147,12 +150,12 @@ fun MakeBitcoinDepositComposable(
fun PreviewMakeBitcoinDepositComposable() {
Surface {
val state = DepositState.FeesChecked(
- effectiveDepositAmount = Amount.fromDouble(CURRENCY_BTC, 42.00),
- totalDepositCost = Amount.fromDouble(CURRENCY_BTC, 42.23),
+ effectiveDepositAmount = Amount.fromString(CURRENCY_BTC, "42.00"),
+ totalDepositCost = Amount.fromString(CURRENCY_BTC, "42.23"),
)
MakeBitcoinDepositComposable(
state = state,
- amount = Amount.fromDouble(CURRENCY_BTC, 42.23)) { _, _ ->
+ amount = Amount.fromString(CURRENCY_BTC, "42.23")) { _, _ ->
}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt
index 8b5cac1..2f9fd88 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt
@@ -22,10 +22,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.OutlinedTextField
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -39,13 +40,15 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.taler.common.Amount
import net.taler.wallet.R
+import net.taler.wallet.transactions.AmountType.Negative
+import net.taler.wallet.transactions.AmountType.Positive
+import net.taler.wallet.transactions.TransactionAmountComposable
@Composable
fun MakeDepositComposable(
@@ -53,7 +56,7 @@ fun MakeDepositComposable(
amount: Amount,
presetName: String? = null,
presetIban: String? = null,
- onMakeDeposit: (Amount, String, String, String) -> Unit,
+ onMakeDeposit: (Amount, String, String) -> Unit,
) {
val scrollState = rememberScrollState()
Column(
@@ -64,23 +67,24 @@ fun MakeDepositComposable(
) {
var name by rememberSaveable { mutableStateOf(presetName ?: "") }
var iban by rememberSaveable { mutableStateOf(presetIban ?: "") }
- var bic by rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
modifier = Modifier
.padding(16.dp)
- .focusRequester(focusRequester),
+ .focusRequester(focusRequester)
+ .fillMaxWidth(),
value = name,
enabled = !state.showFees,
onValueChange = { input ->
name = input
},
+ singleLine = true,
isError = name.isBlank(),
label = {
Text(
stringResource(R.string.send_deposit_name),
color = if (name.isBlank()) {
- colorResource(R.color.red)
+ MaterialTheme.colorScheme.error
} else Color.Unspecified,
)
}
@@ -88,53 +92,40 @@ fun MakeDepositComposable(
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
+ val ibanError = state is DepositState.IbanInvalid
OutlinedTextField(
modifier = Modifier
- .padding(16.dp),
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth(),
value = iban,
+ singleLine = true,
enabled = !state.showFees,
onValueChange = { input ->
- iban = input
+ iban = input.uppercase()
+ },
+ isError = ibanError,
+ supportingText = {
+ if (ibanError) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.send_deposit_iban_error),
+ color = MaterialTheme.colorScheme.error
+ )
+ }
},
- isError = iban.isBlank(),
label = {
Text(
text = stringResource(R.string.send_deposit_iban),
- color = if (iban.isBlank()) {
- colorResource(R.color.red)
+ color = if (ibanError) {
+ MaterialTheme.colorScheme.error
} else Color.Unspecified,
)
}
)
- OutlinedTextField(
- modifier = Modifier
- .padding(16.dp),
- value = bic,
- enabled = !state.showFees,
- onValueChange = { input ->
- bic = input
- },
- label = {
- Text(
- text = stringResource(R.string.send_deposit_bic),
- )
- }
- )
- val amountTitle = if (state.effectiveDepositAmount == null) {
- R.string.amount_chosen
- } else R.string.send_deposit_amount_effective
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- text = stringResource(id = amountTitle),
- )
- val shownAmount = if (state.effectiveDepositAmount == null) amount else {
- state.effectiveDepositAmount
- }
- Text(
- modifier = Modifier.padding(16.dp),
- fontSize = 24.sp,
- color = colorResource(R.color.green),
- text = shownAmount.toString(),
+ TransactionAmountComposable(
+ label = stringResource(R.string.amount_chosen),
+ amount = amount,
+ amountType = Positive,
)
AnimatedVisibility(visible = state.showFees) {
Column(
@@ -143,30 +134,20 @@ fun MakeDepositComposable(
) {
val totalAmount = state.totalDepositCost ?: amount
val effectiveAmount = state.effectiveDepositAmount ?: Amount.zero(amount.currency)
- val fee = totalAmount - effectiveAmount
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- text = stringResource(id = R.string.withdraw_fees),
- )
- Text(
- modifier = Modifier.padding(16.dp),
- fontSize = 24.sp,
- color = colorResource(if (fee.isZero()) R.color.green else R.color.red),
- text = if (fee.isZero()) {
- fee.toString()
- } else {
- stringResource(R.string.amount_negative, fee.toString())
- },
- )
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- text = stringResource(id = R.string.send_amount),
- )
- Text(
- modifier = Modifier.padding(16.dp),
- fontSize = 24.sp,
- color = colorResource(R.color.green),
- text = totalAmount.toString(),
+ if (totalAmount > effectiveAmount) {
+ val fee = totalAmount - effectiveAmount
+
+ TransactionAmountComposable(
+ label = stringResource(R.string.amount_fee),
+ amount = fee.withSpec(amount.spec),
+ amountType = Negative,
+ )
+ }
+
+ TransactionAmountComposable(
+ label = stringResource(R.string.amount_send),
+ amount = effectiveAmount.withSpec(amount.spec),
+ amountType = Positive,
)
}
}
@@ -174,8 +155,8 @@ fun MakeDepositComposable(
Text(
modifier = Modifier.padding(16.dp),
fontSize = 18.sp,
- color = colorResource(R.color.red),
- text = (state as? DepositState.Error)?.msg ?: "",
+ color = MaterialTheme.colorScheme.error,
+ text = (state as? DepositState.Error)?.error?.userFacingMsg ?: "",
)
}
val focusManager = LocalFocusManager.current
@@ -184,13 +165,15 @@ fun MakeDepositComposable(
enabled = iban.isNotBlank(),
onClick = {
focusManager.clearFocus()
- onMakeDeposit(amount, name, iban, bic)
+ onMakeDeposit(amount, name, iban)
},
) {
- Text(text = stringResource(
- if (state.showFees) R.string.send_deposit_create_button
- else R.string.send_deposit_check_fees_button
- ))
+ Text(
+ text = stringResource(
+ if (state is DepositState.FeesChecked) R.string.send_deposit_create_button
+ else R.string.send_deposit_check_fees_button
+ )
+ )
}
}
}
@@ -200,12 +183,12 @@ fun MakeDepositComposable(
fun PreviewMakeDepositComposable() {
Surface {
val state = DepositState.FeesChecked(
- effectiveDepositAmount = Amount.fromDouble("TESTKUDOS", 42.00),
- totalDepositCost = Amount.fromDouble("TESTKUDOS", 42.23),
+ effectiveDepositAmount = Amount.fromString("TESTKUDOS", "42.00"),
+ totalDepositCost = Amount.fromString("TESTKUDOS", "42.23"),
)
MakeDepositComposable(
state = state,
- amount = Amount.fromDouble("TESTKUDOS", 42.23)) { _, _, _, _ ->
+ amount = Amount.fromString("TESTKUDOS", "42.23")) { _, _, _ ->
}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt
index 47e8cfb..0dd3abd 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt
@@ -30,16 +30,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.DropdownMenu
-import androidx.compose.material.DropdownMenuItem
-import androidx.compose.material.LocalTextStyle
-import androidx.compose.material.OutlinedTextField
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
-import androidx.compose.material.TextFieldDefaults
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -48,14 +47,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
@@ -66,6 +64,7 @@ import net.taler.common.Amount
import net.taler.wallet.AmountResult
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
+import net.taler.wallet.compose.AmountInputField
import net.taler.wallet.compose.TalerSurface
class PayToUriFragment : Fragment() {
@@ -85,9 +84,9 @@ class PayToUriFragment : Fragment() {
TalerSurface {
if (currencies.isEmpty()) Text(
text = stringResource(id = R.string.payment_balance_insufficient),
- color = colorResource(id = R.color.red),
+ color = MaterialTheme.colorScheme.error,
) else if (depositManager.isSupportedPayToUri(uri)) PayToComposable(
- currencies = model.getCurrencies(),
+ currencies = currencies,
getAmount = model::createAmount,
onAmountChosen = { amount ->
val u = Uri.parse(uri)
@@ -101,7 +100,7 @@ class PayToUriFragment : Fragment() {
},
) else Text(
text = stringResource(id = R.string.uri_invalid),
- color = colorResource(id = R.color.red),
+ color = MaterialTheme.colorScheme.error,
)
}
}
@@ -130,30 +129,27 @@ private fun PayToComposable(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
- var amountText by rememberSaveable { mutableStateOf("") }
+ var amountText by rememberSaveable { mutableStateOf("0") }
var amountError by rememberSaveable { mutableStateOf("") }
var currency by rememberSaveable { mutableStateOf(currencies[0]) }
val focusRequester = remember { FocusRequester() }
- OutlinedTextField(
- modifier = Modifier
- .focusRequester(focusRequester),
+ AmountInputField(
+ modifier = Modifier.focusRequester(focusRequester),
value = amountText,
onValueChange = { input ->
amountError = ""
amountText = input
},
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Decimal),
- singleLine = true,
+ label = { Text(stringResource(R.string.amount_send)) },
+ supportingText = {
+ if (amountError.isNotBlank()) Text(amountError)
+ },
isError = amountError.isNotBlank(),
- label = {
- if (amountError.isBlank()) {
- Text(stringResource(R.string.send_amount))
- } else {
- Text(amountError, color = colorResource(R.color.red))
- }
- }
)
CurrencyDropdown(
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentSize(Center),
currencies = currencies,
onCurrencyChanged = { c -> currency = c },
)
@@ -162,7 +158,7 @@ private fun PayToComposable(
}
val focusManager = LocalFocusManager.current
- val errorStrInvalidAmount = stringResource(id = R.string.receive_amount_invalid)
+ val errorStrInvalidAmount = stringResource(id = R.string.amount_invalid)
val errorStrInsufficientBalance = stringResource(id = R.string.payment_balance_insufficient)
Button(
modifier = Modifier.padding(16.dp),
@@ -187,25 +183,25 @@ private fun PayToComposable(
fun CurrencyDropdown(
currencies: List<String>,
onCurrencyChanged: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ initialCurrency: String? = null,
+ readOnly: Boolean = false,
) {
- var selectedIndex by remember { mutableStateOf(0) }
+ val initialIndex = currencies.indexOf(initialCurrency).let { if (it < 0) 0 else it }
+ var selectedIndex by remember { mutableStateOf(initialIndex) }
var expanded by remember { mutableStateOf(false) }
Box(
- modifier = Modifier
- .fillMaxSize()
- .wrapContentSize(Alignment.Center),
+ modifier = modifier,
) {
OutlinedTextField(
modifier = Modifier
- .clickable(onClick = { expanded = true }),
+ .clickable(onClick = { if (!readOnly) expanded = true }),
value = currencies[selectedIndex],
onValueChange = { },
readOnly = true,
enabled = false,
textStyle = LocalTextStyle.current.copy( // show text as if not disabled
- color = TextFieldDefaults.outlinedTextFieldColors().textColor(
- enabled = true,
- ).value
+ color = MaterialTheme.colorScheme.onSurfaceVariant
),
singleLine = true,
label = {
@@ -218,13 +214,16 @@ fun CurrencyDropdown(
modifier = Modifier,
) {
currencies.forEachIndexed { index, s ->
- DropdownMenuItem(onClick = {
- selectedIndex = index
- onCurrencyChanged(currencies[index])
- expanded = false
- }) {
- Text(text = s)
- }
+ DropdownMenuItem(
+ text = {
+ Text(text = s)
+ },
+ onClick = {
+ selectedIndex = index
+ onCurrencyChanged(currencies[index])
+ expanded = false
+ }
+ )
}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt
index 7c5c7a2..11264a1 100644
--- a/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt
+++ b/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt
@@ -21,9 +21,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -32,16 +32,31 @@ 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.CurrencySpecification
import net.taler.common.Timestamp
import net.taler.common.toAbsoluteTime
import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED
+import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.transactions.AmountType
-import net.taler.wallet.transactions.DeleteTransactionComposable
+import net.taler.wallet.transactions.ErrorTransactionButton
+import net.taler.wallet.transactions.TransactionAction
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
import net.taler.wallet.transactions.TransactionAmountComposable
import net.taler.wallet.transactions.TransactionDeposit
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import net.taler.wallet.transactions.TransactionState
+import net.taler.wallet.transactions.TransitionsComposable
@Composable
-fun TransactionDepositComposable(t: TransactionDeposit, onDelete: () -> Unit) {
+fun TransactionDepositComposable(
+ t: TransactionDeposit,
+ devMode: Boolean,
+ spec: CurrencySpecification?,
+ onTransition: (t: TransactionAction) -> Unit,
+) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
@@ -53,27 +68,34 @@ fun TransactionDepositComposable(t: TransactionDeposit, onDelete: () -> Unit) {
Text(
modifier = Modifier.padding(16.dp),
text = t.timestamp.ms.toAbsoluteTime(context).toString(),
- style = MaterialTheme.typography.body1,
- )
- TransactionAmountComposable(
- label = stringResource(id = R.string.transaction_paid),
- amount = t.amountEffective,
- amountType = AmountType.Negative,
+ style = MaterialTheme.typography.bodyLarge,
)
+
TransactionAmountComposable(
- label = stringResource(id = R.string.transaction_order_total),
- amount = t.amountRaw,
+ label = stringResource(id = R.string.amount_chosen),
+ amount = t.amountRaw.withSpec(spec),
amountType = AmountType.Neutral,
)
- val fee = t.amountEffective - t.amountRaw
- if (!fee.isZero()) {
+
+ if (t.amountEffective > t.amountRaw) {
+ val fee = t.amountEffective - t.amountRaw
TransactionAmountComposable(
- label = stringResource(id = R.string.withdraw_fees),
- amount = fee,
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(spec),
amountType = AmountType.Negative,
)
}
- DeleteTransactionComposable(onDelete)
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_sent),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+
+ TransitionsComposable(t, devMode, onTransition)
+ if (devMode && t.error != null) {
+ ErrorTransactionButton(error = t.error)
+ }
}
}
@@ -83,13 +105,15 @@ fun TransactionDepositComposablePreview() {
val t = TransactionDeposit(
transactionId = "transactionId",
timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
- pending = true,
+ txState = TransactionState(Pending),
+ txActions = listOf(Retry, Suspend, Abort),
depositGroupId = "fooBar",
- amountRaw = Amount.fromDouble("TESTKUDOS", 42.1337),
- amountEffective = Amount.fromDouble("TESTKUDOS", 42.23),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.1337"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.23"),
targetPaytoUri = "https://exchange.example.org/peer/pull/credit",
+ error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED),
)
Surface {
- TransactionDepositComposable(t) {}
+ TransactionDepositComposable(t, true, null) {}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt b/wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt
new file mode 100644
index 0000000..0ce5c01
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/events/ObservabilityDialog.kt
@@ -0,0 +1,163 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.events
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.activityViewModels
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import net.taler.wallet.MainViewModel
+import net.taler.wallet.R
+import net.taler.wallet.compose.CopyToClipboardButton
+import net.taler.wallet.events.ObservabilityDialog.Companion.json
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+class ObservabilityDialog: DialogFragment() {
+ private val model: MainViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View = ComposeView(requireContext()).apply {
+ setContent {
+ val events by model.observabilityLog.collectAsState()
+ ObservabilityComposable(events.reversed()) {
+ dismiss()
+ }
+ }
+ }
+
+ companion object {
+ @OptIn(ExperimentalSerializationApi::class)
+ val json = Json {
+ prettyPrint = true
+ prettyPrintIndent = " "
+ }
+ }
+}
+
+@Composable
+fun ObservabilityComposable(
+ events: List<ObservabilityEvent>,
+ onDismiss: () -> Unit,
+) {
+ var showJson by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ title = { Text(stringResource(R.string.observability_title)) },
+ text = {
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(events) { event ->
+ ObservabilityItem(event, showJson)
+ }
+ }
+ },
+ onDismissRequest = onDismiss,
+ dismissButton = {
+ Button(onClick = { showJson = !showJson }) {
+ Text(if (showJson) {
+ stringResource(R.string.observability_hide_json)
+ } else {
+ stringResource(R.string.observability_show_json)
+ })
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.close))
+ }
+ },
+ )
+}
+
+@Composable
+fun ObservabilityItem(
+ event: ObservabilityEvent,
+ showJson: Boolean,
+) {
+ val body = json.encodeToString(event.body)
+ val timestamp = DateTimeFormatter
+ .ofLocalizedDateTime(FormatStyle.MEDIUM)
+ .format(event.timestamp)
+
+ ListItem(
+ modifier = Modifier.fillMaxWidth(),
+ headlineContent = { Text(event.type) },
+ overlineContent = { Text(timestamp) },
+ supportingContent = if (!showJson) null else { ->
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier.background(
+ MaterialTheme.colorScheme.secondaryContainer,
+ shape = MaterialTheme.shapes.small,
+ )
+ ) {
+ Text(
+ modifier = Modifier
+ .padding(10.dp)
+ .fillMaxWidth(),
+ text = body,
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ CopyToClipboardButton(
+ label = "Event",
+ content = body,
+ colors = ButtonDefaults.textButtonColors(),
+ )
+ }
+ },
+ )
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/events/ObservabilityEvent.kt b/wallet/src/main/java/net/taler/wallet/events/ObservabilityEvent.kt
new file mode 100644
index 0000000..a50cde2
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/events/ObservabilityEvent.kt
@@ -0,0 +1,66 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.events
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import java.time.LocalDateTime
+
+
+@Serializable(with = ObservabilityEventSerializer::class)
+class ObservabilityEvent(
+ val body: JsonObject,
+ val timestamp: LocalDateTime,
+ val type: String,
+)
+
+class ObservabilityEventSerializer: KSerializer<ObservabilityEvent> {
+ private val jsonElementSerializer = JsonElement.serializer()
+
+ override val descriptor: SerialDescriptor
+ get() = jsonElementSerializer.descriptor
+
+ override fun deserialize(decoder: Decoder): ObservabilityEvent {
+ require(decoder is JsonDecoder)
+ val jsonObject = decoder
+ .decodeJsonElement()
+ .jsonObject
+
+ val type = jsonObject["type"]
+ ?.jsonPrimitive
+ ?.content
+ ?: "unknown"
+
+ return ObservabilityEvent(
+ body = jsonObject,
+ timestamp = LocalDateTime.now(),
+ type = type,
+ )
+ }
+
+ override fun serialize(encoder: Encoder, value: ObservabilityEvent) {
+ encoder.encodeSerializableValue(JsonObject.serializer(), value.body)
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/AddExchangeDialogFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/AddExchangeDialogFragment.kt
index 5ea763a..fd942b0 100644
--- a/wallet/src/main/java/net/taler/wallet/exchanges/AddExchangeDialogFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/AddExchangeDialogFragment.kt
@@ -22,6 +22,7 @@ import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
@@ -32,7 +33,7 @@ class AddExchangeDialogFragment : DialogFragment() {
private val exchangeManager by lazy { model.exchangeManager }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- return AlertDialog.Builder(requireContext(), R.style.DialogTheme)
+ return MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3)
.setIcon(R.drawable.ic_account_balance)
.setTitle(R.string.exchange_list_add)
.setView(R.layout.dialog_exchange_add)
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt
index e0cf5be..674632e 100644
--- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt
@@ -26,25 +26,15 @@ import android.widget.TextView
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter
-import kotlinx.serialization.Serializable
import net.taler.wallet.R
-import net.taler.wallet.cleanExchange
import net.taler.wallet.exchanges.ExchangeAdapter.ExchangeItemViewHolder
-@Serializable
-data class ExchangeItem(
- val exchangeBaseUrl: String,
- // can be null before exchange info in wallet-core was fully loaded
- val currency: String? = null,
- val paytoUris: List<String>,
-) {
- val name: String get() = cleanExchange(exchangeBaseUrl)
-}
-
interface ExchangeClickListener {
fun onExchangeSelected(item: ExchangeItem)
fun onManualWithdraw(item: ExchangeItem)
fun onPeerReceive(item: ExchangeItem)
+ fun onExchangeReload(item: ExchangeItem)
+ fun onExchangeDelete(item: ExchangeItem)
}
internal class ExchangeAdapter(
@@ -80,8 +70,9 @@ internal class ExchangeAdapter(
fun bind(item: ExchangeItem) {
urlView.text = item.name
+ // If currency is null, it's because we have no data from the exchange...
currencyView.text = if (item.currency == null) {
- context.getString(R.string.settings_version_unknown)
+ context.getString(R.string.exchange_not_contacted)
} else {
context.getString(R.string.exchange_list_currency, item.currency)
}
@@ -91,7 +82,8 @@ internal class ExchangeAdapter(
} else {
itemView.setOnClickListener(null)
itemView.isClickable = false
- overflowIcon.visibility = VISIBLE
+ // ...thus, we should prevent the user from interacting with it.
+ overflowIcon.visibility = if (item.currency != null) VISIBLE else GONE
}
overflowIcon.setOnClickListener { openMenu(overflowIcon, item) }
}
@@ -108,6 +100,14 @@ internal class ExchangeAdapter(
listener.onPeerReceive(item)
true
}
+ R.id.action_reload -> {
+ listener.onExchangeReload(item)
+ true
+ }
+ R.id.action_delete -> {
+ listener.onExchangeDelete(item)
+ true
+ }
else -> false
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeFeesFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeFeesFragment.kt
index d8242f3..d42ab2c 100644
--- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeFeesFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeFeesFragment.kt
@@ -22,7 +22,6 @@ import android.view.View
import android.view.View.GONE
import android.view.ViewGroup
import android.widget.TextView
-import androidx.core.content.ContextCompat.getColor
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.RecyclerView.Adapter
@@ -35,6 +34,7 @@ import net.taler.wallet.R
import net.taler.wallet.databinding.FragmentExchangeFeesBinding
import net.taler.wallet.exchanges.CoinFeeAdapter.CoinFeeViewHolder
import net.taler.wallet.exchanges.WireFeeAdapter.WireFeeViewHolder
+import net.taler.wallet.getAttrColor
class ExchangeFeesFragment : Fragment() {
@@ -71,7 +71,7 @@ class ExchangeFeesFragment : Fragment() {
if (amount.isZero()) text = amount.toString()
else {
text = getString(R.string.amount_negative, amount)
- setTextColor(getColor(context, R.color.red))
+ setText(requireContext().getAttrColor(R.attr.colorError))
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt
index 44c98c1..8a40bff 100644
--- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt
@@ -18,21 +18,29 @@ package net.taler.wallet.exchanges
import android.os.Bundle
import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
+import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle.State.RESUMED
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import net.taler.common.EventObserver
import net.taler.common.fadeIn
import net.taler.common.fadeOut
+import net.taler.common.showError
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
import net.taler.wallet.databinding.FragmentExchangeListBinding
+import net.taler.wallet.showError
open class ExchangeListFragment : Fragment(), ExchangeClickListener {
@@ -54,6 +62,21 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ requireActivity().addMenuProvider(object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ if (model.devMode.value == true) {
+ menuInflater.inflate(R.menu.exchange_list, menu)
+ }
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ if (menuItem.itemId == R.id.action_add_dev_exchanges) {
+ exchangeManager.addDevExchanges()
+ }
+ return true
+ }
+ }, viewLifecycleOwner, RESUMED)
+
ui.list.apply {
adapter = exchangeAdapter
addItemDecoration(DividerItemDecoration(context, VERTICAL))
@@ -69,7 +92,30 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener {
onExchangeUpdate(exchanges)
}
exchangeManager.addError.observe(viewLifecycleOwner, EventObserver { error ->
- if (error) onAddExchangeFailed()
+ onAddExchangeFailed()
+ if (model.devMode.value == true) {
+ showError(error)
+ }
+ })
+ exchangeManager.listError.observe(viewLifecycleOwner, EventObserver { error ->
+ onListExchangeFailed()
+ if (model.devMode.value == true) {
+ showError(error)
+ }
+ })
+ exchangeManager.deleteError.observe(viewLifecycleOwner, EventObserver { error ->
+ if (model.devMode.value == true) {
+ showError(error)
+ } else {
+ showError(error.userFacingMsg)
+ }
+ })
+ exchangeManager.reloadError.observe(viewLifecycleOwner, EventObserver { error ->
+ if (model.devMode.value == true) {
+ showError(error)
+ } else {
+ showError(error.userFacingMsg)
+ }
})
}
@@ -88,6 +134,10 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener {
Toast.makeText(requireContext(), R.string.exchange_add_error, LENGTH_LONG).show()
}
+ private fun onListExchangeFailed() {
+ Toast.makeText(requireContext(), R.string.exchange_list_error, LENGTH_LONG).show()
+ }
+
override fun onExchangeSelected(item: ExchangeItem) {
throw AssertionError("must not get triggered here")
}
@@ -98,8 +148,27 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener {
}
override fun onPeerReceive(item: ExchangeItem) {
- transactionManager.selectedCurrency = item.currency
- findNavController().navigate(R.id.receiveFunds)
+ transactionManager.selectedScope = item.scopeInfo
+ findNavController().navigate(R.id.action_global_receiveFunds)
+ }
+
+ override fun onExchangeReload(item: ExchangeItem) {
+ exchangeManager.reload(item.exchangeBaseUrl)
}
+ override fun onExchangeDelete(item: ExchangeItem) {
+ val optionsArray = arrayOf(getString(R.string.exchange_delete_force))
+ val checkedArray = BooleanArray(1) { false }
+
+ MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3)
+ .setTitle(R.string.exchange_delete)
+ .setMultiChoiceItems(optionsArray, checkedArray) { _, which, isChecked ->
+ checkedArray[which] = isChecked
+ }
+ .setNegativeButton(R.string.transactions_delete) { _, _ ->
+ exchangeManager.delete(item.exchangeBaseUrl, checkedArray[0])
+ }
+ .setPositiveButton(R.string.cancel) { _, _ -> }
+ .show()
+ }
}
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt
index 5a4c6c2..fa357b5 100644
--- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt
@@ -17,9 +17,11 @@
package net.taler.wallet.exchanges
import android.util.Log
+import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
@@ -27,6 +29,7 @@ import kotlinx.serialization.Serializable
import net.taler.common.Event
import net.taler.common.toEvent
import net.taler.wallet.TAG
+import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.backend.WalletBackendApi
@Serializable
@@ -34,6 +37,11 @@ data class ExchangeListResponse(
val exchanges: List<ExchangeItem>,
)
+@Serializable
+data class ExchangeDetailedResponse(
+ val exchange: ExchangeItem,
+)
+
class ExchangeManager(
private val api: WalletBackendApi,
private val scope: CoroutineScope,
@@ -45,8 +53,17 @@ class ExchangeManager(
private val mExchanges = MutableLiveData<List<ExchangeItem>>()
val exchanges: LiveData<List<ExchangeItem>> get() = list()
- private val mAddError = MutableLiveData<Event<Boolean>>()
- val addError: LiveData<Event<Boolean>> = mAddError
+ private val mAddError = MutableLiveData<Event<TalerErrorInfo>>()
+ val addError: LiveData<Event<TalerErrorInfo>> = mAddError
+
+ private val mListError = MutableLiveData<Event<TalerErrorInfo>>()
+ val listError: LiveData<Event<TalerErrorInfo>> = mListError
+
+ private val mDeleteError = MutableLiveData<Event<TalerErrorInfo>>()
+ val deleteError: LiveData<Event<TalerErrorInfo>> = mDeleteError
+
+ private val mReloadError = MutableLiveData<Event<TalerErrorInfo>>()
+ val reloadError: LiveData<Event<TalerErrorInfo>> = mReloadError
var withdrawalExchange: ExchangeItem? = null
@@ -55,7 +72,8 @@ class ExchangeManager(
scope.launch {
val response = api.request("listExchanges", ExchangeListResponse.serializer())
response.onError {
- throw AssertionError("Wallet core failed to return exchanges! ${it.userFacingMsg}")
+ mProgress.value = false
+ mListError.value = it.toEvent()
}.onSuccess {
Log.d(TAG, "Exchange list: ${it.exchanges}")
mProgress.value = false
@@ -70,9 +88,9 @@ class ExchangeManager(
api.request<Unit>("addExchange") {
put("exchangeBaseUrl", exchangeUrl)
}.onError {
- mProgress.value = false
Log.e(TAG, "Error adding exchange: $it")
- mAddError.value = true.toEvent()
+ mProgress.value = false
+ mAddError.value = it.toEvent()
}.onSuccess {
mProgress.value = false
Log.d(TAG, "Exchange $exchangeUrl added")
@@ -80,14 +98,98 @@ class ExchangeManager(
}
}
+ fun reload(exchangeUrl: String, force: Boolean = true) = scope.launch {
+ mProgress.value = true
+ api.request<Unit>("updateExchangeEntry") {
+ put("exchangeBaseUrl", exchangeUrl)
+ put("force", force)
+ }.onError {
+ Log.e(TAG, "Error reloading exchange: $it")
+ mProgress.value = false
+ mReloadError.value = it.toEvent()
+ }.onSuccess {
+ mProgress.value = false
+ Log.d(TAG, "Exchange $exchangeUrl reloaded")
+ list()
+ }
+ }
+
+ fun delete(exchangeUrl: String, purge: Boolean = false) = scope.launch {
+ mProgress.value = true
+ api.request<Unit>("deleteExchange") {
+ put("exchangeBaseUrl", exchangeUrl)
+ put("purge", purge)
+ }.onError {
+ Log.e(TAG, "Error deleting exchange: $it")
+ mProgress.value = false
+ mDeleteError.value = it.toEvent()
+ }.onSuccess {
+ mProgress.value = false
+ Log.d(TAG, "Exchange $exchangeUrl deleted")
+ list()
+ }
+ }
+
fun findExchangeForCurrency(currency: String): Flow<ExchangeItem?> = flow {
- val response = api.request("listExchanges", ExchangeListResponse.serializer())
+ emit(findExchange(currency))
+ }
+
+ @WorkerThread
+ suspend fun findExchange(currency: String): ExchangeItem? {
var exchange: ExchangeItem? = null
- response.onSuccess { exchangeListResponse ->
+ api.request(
+ operation = "listExchanges",
+ serializer = ExchangeListResponse.serializer()
+ ).onSuccess { exchangeListResponse ->
// just pick the first for now
exchange = exchangeListResponse.exchanges.find { it.currency == currency }
}
- emit(exchange)
+ return exchange
+ }
+
+ @WorkerThread
+ suspend fun findExchangeByUrl(exchangeUrl: String): ExchangeItem? {
+ var exchange: ExchangeItem? = null
+ api.request("getExchangeDetailedInfo", ExchangeDetailedResponse.serializer()) {
+ put("exchangeBaseUrl", exchangeUrl)
+ }.onError {
+ Log.e(TAG, "Error getExchangeDetailedInfo: $it")
+ }.onSuccess {
+ exchange = it.exchange
+ }
+ return exchange
+ }
+
+ fun addDevExchanges() {
+ scope.launch {
+ listOf(
+ "https://exchange.demo.taler.net/",
+ "https://exchange.test.taler.net/",
+ "https://exchange.head.taler.net/",
+ "https://exchange.taler.ar/",
+ "https://exchange.taler.fdold.eu/",
+ "https://exchange.taler.grothoff.org/",
+ ).forEach { exchangeUrl ->
+ add(exchangeUrl)
+ delay(100)
+ }
+ exchanges.value?.let { exs ->
+ exs.find {
+ it.exchangeBaseUrl.startsWith("https://exchange.taler.fdold.eu")
+ }?.let { fDoldExchange ->
+ api.request<Unit>("addGlobalCurrencyExchange") {
+ put("currency", fDoldExchange.currency)
+ put("exchangeBaseUrl", fDoldExchange.exchangeBaseUrl)
+ put("exchangeMasterPub",
+ "7ER30ZWJEXAG026H5KG9M19NGTFC2DKKFPV79GVXA6DK5DCNSWXG")
+ }.onError {
+ Log.e(TAG, "Error addGlobalCurrencyExchange: $it")
+ }.onSuccess {
+ Log.i(TAG, "fdold is global now!")
+ }
+ }
+ }
+ }
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt b/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt
new file mode 100644
index 0000000..0015e1c
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt
@@ -0,0 +1,38 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.exchanges
+
+import kotlinx.serialization.Serializable
+import net.taler.wallet.balances.ScopeInfo
+import net.taler.wallet.cleanExchange
+
+@Serializable
+data class BuiltinExchange(
+ val exchangeBaseUrl: String,
+ val currencyHint: String? = null,
+)
+
+@Serializable
+data class ExchangeItem(
+ val exchangeBaseUrl: String,
+ // can be null before exchange info in wallet-core was fully loaded
+ val currency: String? = null,
+ val paytoUris: List<String>,
+ val scopeInfo: ScopeInfo? = null,
+) {
+ val name: String get() = cleanExchange(exchangeBaseUrl)
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeDialogFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeDialogFragment.kt
new file mode 100644
index 0000000..136738b
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeDialogFragment.kt
@@ -0,0 +1,111 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.exchanges
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.res.stringResource
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.asFlow
+import com.google.accompanist.themeadapter.material3.Mdc3Theme
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import net.taler.common.Event
+import net.taler.common.toEvent
+import net.taler.wallet.R
+import net.taler.wallet.cleanExchange
+import net.taler.wallet.compose.collectAsStateLifecycleAware
+
+class SelectExchangeDialogFragment: DialogFragment() {
+ private var exchangeList = MutableLiveData<List<ExchangeItem>>()
+
+ private var mExchangeSelection = MutableLiveData<Event<ExchangeItem>>()
+ val exchangeSelection: LiveData<Event<ExchangeItem>> = mExchangeSelection
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val view = ComposeView(requireContext()).apply {
+ setContent {
+ val exchanges = exchangeList.asFlow().collectAsStateLifecycleAware(initial = emptyList())
+ SelectExchangeComposable(exchanges.value) {
+ onExchangeSelected(it)
+ }
+ }
+ }
+
+ return MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3)
+ .setIcon(R.drawable.ic_account_balance)
+ .setTitle(R.string.exchange_list_select)
+ .setView(view)
+ .setNegativeButton(R.string.cancel) { _, _ ->
+ dismiss()
+ }
+ .create()
+ }
+
+ fun setExchanges(exchanges: List<ExchangeItem>) {
+ exchangeList.value = exchanges
+ }
+
+ private fun onExchangeSelected(exchange: ExchangeItem) {
+ mExchangeSelection.value = exchange.toEvent()
+ dismiss()
+ }
+}
+
+@Composable
+fun SelectExchangeComposable(
+ exchanges: List<ExchangeItem>,
+ onExchangeSelected: (exchange: ExchangeItem) -> Unit,
+) {
+ Mdc3Theme {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(exchanges) {
+ ExchangeItemComposable(it) {
+ onExchangeSelected(it)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ExchangeItemComposable(exchange: ExchangeItem, onSelected: () -> Unit) {
+ ListItem(
+ modifier = Modifier.clickable { onSelected() },
+ headlineContent = { Text(cleanExchange(exchange.exchangeBaseUrl)) },
+ supportingContent = exchange.currency?.let {
+ { Text(stringResource(R.string.exchange_list_currency, it)) }
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ )
+ )
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeFragment.kt
deleted file mode 100644
index 61e0db5..0000000
--- a/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeFragment.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
- *
- * GNU Taler is free software; you can redistribute it and/or modify it under the
- * terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3, or (at your option) any later version.
- *
- * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-package net.taler.wallet.exchanges
-
-import androidx.navigation.fragment.findNavController
-import net.taler.common.fadeOut
-
-class SelectExchangeFragment : ExchangeListFragment() {
-
- private val withdrawManager by lazy { model.withdrawManager }
-
- override val isSelectOnly = true
- private val exchangeSelection by lazy {
- requireNotNull(withdrawManager.exchangeSelection.value?.getEvenIfConsumedAlready())
- }
-
- override fun onExchangeUpdate(exchanges: List<ExchangeItem>) {
- ui.progressBar.fadeOut()
- super.onExchangeUpdate(exchanges.filter { exchangeItem ->
- exchangeItem.currency == exchangeSelection.amount.currency
- })
- }
-
- override fun onExchangeSelected(item: ExchangeItem) {
- withdrawManager.getWithdrawalDetails(
- exchangeBaseUrl = item.exchangeBaseUrl,
- amount = exchangeSelection.amount,
- showTosImmediately = true,
- uri = exchangeSelection.talerWithdrawUri,
- )
- findNavController().navigateUp()
- }
-
-}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt
deleted file mode 100644
index df2b2b8..0000000
--- a/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
- *
- * GNU Taler is free software; you can redistribute it and/or modify it under the
- * terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3, or (at your option) any later version.
- *
- * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-package net.taler.wallet.payment
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.fragment.app.Fragment
-import androidx.navigation.fragment.findNavController
-import net.taler.wallet.databinding.FragmentAlreadyPaidBinding
-
-/**
- * Display the message that the user already paid for the order
- * that the merchant is proposing.
- */
-class AlreadyPaidFragment : Fragment() {
-
- private lateinit var ui: FragmentAlreadyPaidBinding
-
- override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View? {
- ui = FragmentAlreadyPaidBinding.inflate(inflater, container, false)
- return ui.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- ui.backButton.setOnClickListener {
- findNavController().navigateUp()
- }
- }
-
-}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt
new file mode 100644
index 0000000..d744183
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt
@@ -0,0 +1,171 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.payment
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.Center
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.common.ContractTerms
+import net.taler.wallet.AmountResult
+import net.taler.wallet.R
+import net.taler.wallet.compose.LoadingScreen
+import net.taler.wallet.compose.TalerSurface
+
+@Composable
+fun PayTemplateComposable(
+ currencies: List<String>,
+ payStatus: PayStatus,
+ onCreateAmount: (String, String) -> AmountResult,
+ onSubmit: (params: TemplateParams) -> Unit,
+ onError: (resId: Int) -> Unit,
+) {
+ // If wallet is empty, there's no way the user can pay something
+ if (currencies.isEmpty()) {
+ PayTemplateError(stringResource(R.string.payment_balance_insufficient))
+ } else when (val p = payStatus) {
+ is PayStatus.Checked -> {
+ val usableCurrencies = currencies
+ .intersect(p.supportedCurrencies.toSet())
+ .toList()
+ if (usableCurrencies.isEmpty()) {
+ // If user doesn't have any supported currency, they can't pay either
+ PayTemplateError(stringResource(R.string.payment_balance_insufficient))
+ } else {
+ PayTemplateOrderComposable(
+ usableCurrencies = usableCurrencies,
+ templateDetails = p.details,
+ onCreateAmount = onCreateAmount,
+ onError = onError,
+ onSubmit = onSubmit,
+ )
+ }
+ }
+
+ is PayStatus.None, is PayStatus.Loading -> PayTemplateLoading()
+ is PayStatus.AlreadyPaid -> PayTemplateError(stringResource(R.string.payment_already_paid))
+ is PayStatus.InsufficientBalance -> PayTemplateError(stringResource(R.string.payment_balance_insufficient))
+ is PayStatus.Pending -> {
+ val error = p.error
+ PayTemplateError(if (error != null) {
+ stringResource(R.string.payment_error, error.userFacingMsg)
+ } else {
+ stringResource(R.string.payment_template_error)
+ })
+ }
+ is PayStatus.Prepared -> {} // handled in fragment, will redirect
+ is PayStatus.Success -> {} // handled by other UI flow, no need for content here
+ }
+}
+
+@Composable
+fun PayTemplateError(message: String) {
+ Box(
+ modifier = Modifier.padding(16.dp).fillMaxSize(),
+ contentAlignment = Center,
+ ) {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+}
+
+@Composable
+fun PayTemplateLoading() {
+ LoadingScreen()
+}
+
+@Preview
+@Composable
+fun PayTemplateLoadingPreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ payStatus = PayStatus.Loading,
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { _ -> },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateInsufficientBalancePreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ payStatus = PayStatus.InsufficientBalance(
+ ContractTerms(
+ "test",
+ amount = Amount.zero("TESTKUDOS"),
+ products = emptyList()
+ ), Amount.zero("TESTKUDOS")
+ ),
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { _ -> },
+ )
+ }
+}
+
+@Preview(widthDp = 300)
+@Composable
+fun PayTemplateAlreadyPaidPreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ payStatus = PayStatus.AlreadyPaid(transactionId = "transactionId"),
+ currencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { _ -> },
+ )
+ }
+}
+
+
+@Preview
+@Composable
+fun PayTemplateNoCurrenciesPreview() {
+ TalerSurface {
+ PayTemplateComposable(
+ payStatus = PayStatus.None,
+ currencies = emptyList(),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { _ -> },
+ )
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt
new file mode 100644
index 0000000..4c3f0a5
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt
@@ -0,0 +1,126 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.payment
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.taler.common.Amount
+import net.taler.common.RelativeTime
+
+@Serializable
+data class TemplateContractDetails(
+ /**
+ * Human-readable summary for the template.
+ */
+ val summary: String? = null,
+
+ /**
+ * Required currency for payments to the template. The user may specify
+ * any amount, but it must be in this currency. This parameter is
+ * optional and should not be present if "amount" is given.
+ */
+ val currency: String? = null,
+
+ /**
+ * The price is imposed by the merchant and cannot be changed by the
+ * customer. This parameter is optional.
+ */
+ val amount: Amount? = null,
+
+ /**
+ * Minimum age buyer must have (in years). Default is 0.
+ */
+ @SerialName("minimum_age")
+ val minimumAge: Int,
+
+ /**
+ * The time the customer need to pay before his order will be deleted. It
+ * is deleted if the customer did not pay and if the duration is over.
+ */
+ @SerialName("pay_duration")
+ val payDuration: RelativeTime,
+)
+
+@Serializable
+data class TemplateContractDetailsDefaults(
+ val summary: String? = null,
+ val currency: String? = null,
+ val amount: Amount? = null,
+ @SerialName("minimum_age")
+ val minimumAge: Int? = null,
+)
+
+@Serializable
+class WalletTemplateDetails(
+ /**
+ * Hard-coded information about the contract terms for this template.
+ */
+ @SerialName("template_contract")
+ val templateContract: TemplateContractDetails,
+
+ /**
+ * Key-value pairs matching a subset of the fields from template_contract
+ * that are user-editable defaults for this template.
+ */
+ @SerialName("editable_defaults")
+ val editableDefaults: TemplateContractDetailsDefaults? = null,
+
+ /**
+ * Required currency for payments. Useful if no amount is specified in
+ * the template_contract but the user should be required to pay in a
+ * particular currency anyway. Merchant backends may reject requests if
+ * the template_contract or editable_defaults do specify an amount in a
+ * different currency. This parameter is optional.
+ */
+ @SerialName("required_currency")
+ val requiredCurrency: String? = null,
+) {
+ val defaultSummary get() = editableDefaults?.summary
+ ?: templateContract.summary
+
+ val defaultAmount get() = editableDefaults?.amount
+ ?: templateContract.amount
+
+ val defaultCurrency get() = requiredCurrency
+ ?: editableDefaults?.currency
+ ?: templateContract.currency
+
+ fun isSummaryEditable() = templateContract.summary == null
+
+ fun isAmountEditable() = templateContract.amount == null
+
+ fun isCurrencyEditable(usableCurrencies: List<String>) = isAmountEditable()
+ && requiredCurrency == null
+ && templateContract.currency == null
+ && usableCurrencies.size > 1
+
+ fun isTemplateEditable(usableCurrencies: List<String>) = isSummaryEditable()
+ || isAmountEditable()
+ || isCurrencyEditable(usableCurrencies)
+
+ // NOTE: it is important to nullify non-editable values!
+ fun toTemplateParams() = TemplateParams(
+ amount = if(isAmountEditable()) templateContract.amount else null,
+ summary = if(isSummaryEditable()) templateContract.summary else null,
+ )
+}
+
+@Serializable
+data class TemplateParams(
+ val amount: Amount? = null,
+ val summary: String? = null,
+) \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt
new file mode 100644
index 0000000..51c0bc0
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt
@@ -0,0 +1,104 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.payment
+
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.ui.platform.ComposeView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.asFlow
+import androidx.navigation.fragment.findNavController
+import net.taler.common.showError
+import net.taler.wallet.MainViewModel
+import net.taler.wallet.R
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.compose.collectAsStateLifecycleAware
+import net.taler.wallet.showError
+
+class PayTemplateFragment : Fragment() {
+
+ private val model: MainViewModel by activityViewModels()
+ private lateinit var uriString: String
+ private lateinit var uri: Uri
+ private val currencies by lazy { model.getCurrencies() }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ uriString = arguments?.getString("uri") ?: error("no amount passed")
+ uri = Uri.parse(uriString)
+
+ val payStatusFlow = model.paymentManager.payStatus.asFlow()
+
+ return ComposeView(requireContext()).apply {
+ setContent {
+ val payStatus = payStatusFlow.collectAsStateLifecycleAware(initial = PayStatus.None)
+ TalerSurface {
+ PayTemplateComposable(
+ currencies = currencies,
+ payStatus = payStatus.value,
+ onCreateAmount = model::createAmount,
+ onSubmit = this@PayTemplateFragment::createOrder,
+ onError = { this@PayTemplateFragment.showError(it) },
+ )
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ checkTemplate()
+
+ model.paymentManager.payStatus.observe(viewLifecycleOwner) { payStatus ->
+ when (payStatus) {
+ is PayStatus.Prepared -> {
+ findNavController().navigate(R.id.action_promptPayTemplate_to_promptPayment)
+ }
+
+ is PayStatus.Pending -> if (payStatus.error != null && model.devMode.value == true) {
+ showError(payStatus.error)
+ }
+
+ is PayStatus.Checked -> {
+ val usableCurrencies = currencies
+ .intersect(payStatus.supportedCurrencies.toSet())
+ .toList()
+ if (!payStatus.details.isTemplateEditable(usableCurrencies)) {
+ createOrder(payStatus.details.toTemplateParams())
+ }
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ private fun checkTemplate() {
+ model.paymentManager.checkPayForTemplate(uriString)
+ }
+
+ private fun createOrder(params: TemplateParams) {
+ model.paymentManager.preparePayForTemplate(uriString, params)
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt
new file mode 100644
index 0000000..2febfbb
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt
@@ -0,0 +1,221 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.payment
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment.Companion.End
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.common.RelativeTime
+import net.taler.wallet.AmountResult
+import net.taler.wallet.R
+import net.taler.wallet.compose.AmountInputField
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.deposit.CurrencyDropdown
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun PayTemplateOrderComposable(
+ usableCurrencies: List<String>, // non-empty intersection between the stored currencies and the ones supported by the merchant
+ templateDetails: WalletTemplateDetails,
+ onCreateAmount: (String, String) -> AmountResult,
+ onError: (msgRes: Int) -> Unit,
+ onSubmit: (params: TemplateParams) -> Unit,
+) {
+ val defaultSummary = templateDetails.defaultSummary
+ val defaultAmount = templateDetails.defaultAmount
+ val defaultCurrency = templateDetails.defaultCurrency
+
+ val summaryFocusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ var summary by remember { mutableStateOf(defaultSummary ?: "") }
+ var currency by remember { mutableStateOf(defaultCurrency ?: usableCurrencies[0]) }
+ var amount by remember { mutableStateOf(defaultAmount?.amountStr ?: "0") }
+
+ Column(horizontalAlignment = End) {
+ OutlinedTextField(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth()
+ .focusRequester(summaryFocusRequester)
+ .onFocusChanged {
+ if (it.isFocused) {
+ keyboardController?.show()
+ }
+ },
+ value = summary,
+ isError = templateDetails.isSummaryEditable() && summary.isBlank(),
+ onValueChange = { summary = it },
+ singleLine = true,
+ readOnly = !templateDetails.isSummaryEditable(),
+ label = { Text(stringResource(R.string.withdraw_manual_ready_subject)) },
+ )
+
+ AmountField(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ amount = amount,
+ currency = currency,
+ currencies = usableCurrencies,
+ readOnlyCurrency = !templateDetails.isCurrencyEditable(usableCurrencies),
+ readOnlyAmount = !templateDetails.isAmountEditable(),
+ onAmountChosen = { a, c ->
+ amount = a
+ currency = c
+ },
+ )
+
+ Button(
+ modifier = Modifier.padding(16.dp),
+ enabled = !templateDetails.isSummaryEditable() || summary.isNotBlank(),
+ onClick = {
+ when (val res = onCreateAmount(amount, currency)) {
+ is AmountResult.InsufficientBalance -> onError(R.string.payment_balance_insufficient)
+ is AmountResult.InvalidAmount -> onError(R.string.amount_invalid)
+ // NOTE: it is important to nullify non-editable values!
+ is AmountResult.Success -> onSubmit(TemplateParams(
+ summary = if (templateDetails.isSummaryEditable()) summary else null,
+ amount = if(templateDetails.isAmountEditable()) res.amount else null,
+ ))
+ }
+ },
+ ) {
+ Text(stringResource(R.string.payment_create_order))
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ if (templateDetails.isSummaryEditable()
+ && templateDetails.defaultSummary == null) {
+ summaryFocusRequester.requestFocus()
+ }
+ }
+}
+
+@Composable
+private fun AmountField(
+ modifier: Modifier = Modifier,
+ currencies: List<String>,
+ amount: String,
+ currency: String,
+ readOnlyAmount: Boolean = true,
+ readOnlyCurrency: Boolean = true,
+ onAmountChosen: (amount: String, currency: String) -> Unit,
+) {
+ Row(
+ modifier = modifier,
+ ) {
+ AmountInputField(
+ modifier = Modifier
+ .padding(end = 16.dp)
+ .weight(1f),
+ value = amount,
+ onValueChange = { onAmountChosen(it, currency) },
+ label = { Text(stringResource(R.string.amount_send)) },
+ readOnly = readOnlyAmount,
+ )
+
+ CurrencyDropdown(
+ modifier = Modifier.weight(1f),
+ initialCurrency = currency,
+ currencies = currencies,
+ onCurrencyChanged = { onAmountChosen(amount, it) },
+ readOnly = readOnlyCurrency,
+ )
+ }
+}
+
+val defaultTemplateDetails = WalletTemplateDetails(
+ templateContract = TemplateContractDetails(
+ minimumAge = 18,
+ payDuration = RelativeTime.forever(),
+ ),
+ editableDefaults = TemplateContractDetailsDefaults(
+ summary = "Donation",
+ amount = Amount.fromJSONString("KUDOS:10.0"),
+ ),
+)
+
+@Preview
+@Composable
+fun PayTemplateDefaultPreview() {
+ TalerSurface {
+ PayTemplateOrderComposable(
+ templateDetails = defaultTemplateDetails,
+ usableCurrencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateFixedAmountPreview() {
+ TalerSurface {
+ PayTemplateOrderComposable(
+ templateDetails = defaultTemplateDetails,
+ usableCurrencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PayTemplateBlankSubjectPreview() {
+ TalerSurface {
+ PayTemplateOrderComposable(
+ templateDetails = defaultTemplateDetails,
+ usableCurrencies = listOf("KUDOS", "ARS"),
+ onCreateAmount = { text, currency ->
+ AmountResult.Success(amount = Amount.fromString(currency, text))
+ },
+ onSubmit = { _ -> },
+ onError = { },
+ )
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
index 53cb259..647c98c 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
@@ -22,9 +22,12 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
import net.taler.common.Amount
import net.taler.common.ContractTerms
import net.taler.wallet.TAG
+import net.taler.wallet.backend.BackendManager
import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.backend.WalletBackendApi
import net.taler.wallet.payment.PayStatus.AlreadyPaid
@@ -32,30 +35,50 @@ import net.taler.wallet.payment.PayStatus.InsufficientBalance
import net.taler.wallet.payment.PreparePayResponse.AlreadyConfirmedResponse
import net.taler.wallet.payment.PreparePayResponse.InsufficientBalanceResponse
import net.taler.wallet.payment.PreparePayResponse.PaymentPossibleResponse
+import org.json.JSONObject
val REGEX_PRODUCT_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$")
sealed class PayStatus {
- object None : PayStatus()
- object Loading : PayStatus()
+ data object None : PayStatus()
+ data object Loading : PayStatus()
data class Prepared(
val contractTerms: ContractTerms,
- val proposalId: String,
+ val transactionId: String,
val amountRaw: Amount,
val amountEffective: Amount,
) : PayStatus()
+ data class Checked(
+ val details: WalletTemplateDetails,
+ val supportedCurrencies: List<String>,
+ ) : PayStatus()
+
data class InsufficientBalance(
val contractTerms: ContractTerms,
val amountRaw: Amount,
) : PayStatus()
- // TODO bring user to fulfilment URI
- object AlreadyPaid : PayStatus()
- data class Error(val error: String) : PayStatus()
- data class Success(val currency: String) : PayStatus()
+ data class AlreadyPaid(
+ val transactionId: String,
+ ) : PayStatus()
+
+ data class Pending(
+ val transactionId: String? = null,
+ val error: TalerErrorInfo? = null,
+ ) : PayStatus()
+ data class Success(
+ val transactionId: String,
+ val currency: String,
+ ) : PayStatus()
}
+@Serializable
+data class CheckPayTemplateResponse(
+ val templateDetails: WalletTemplateDetails,
+ val supportedCurrencies: List<String>,
+)
+
class PaymentManager(
private val api: WalletBackendApi,
private val scope: CoroutineScope,
@@ -75,42 +98,68 @@ class PaymentManager(
mPayStatus.value = when (response) {
is PaymentPossibleResponse -> response.toPayStatusPrepared()
is InsufficientBalanceResponse -> InsufficientBalance(
- response.contractTerms,
- response.amountRaw
+ contractTerms = response.contractTerms,
+ amountRaw = response.amountRaw
+ )
+ is AlreadyConfirmedResponse -> AlreadyPaid(
+ transactionId = response.transactionId,
)
- is AlreadyConfirmedResponse -> AlreadyPaid
}
}
}
- fun confirmPay(proposalId: String, currency: String) = scope.launch {
+ fun confirmPay(transactionId: String, currency: String) = scope.launch {
api.request("confirmPay", ConfirmPayResult.serializer()) {
- put("proposalId", proposalId)
+ put("transactionId", transactionId)
}.onError {
handleError("confirmPay", it)
- }.onSuccess {
- mPayStatus.postValue(PayStatus.Success(currency))
+ }.onSuccess { response ->
+ mPayStatus.postValue(when (response) {
+ is ConfirmPayResult.Done -> PayStatus.Success(
+ transactionId = response.transactionId,
+ currency = currency,
+ )
+ is ConfirmPayResult.Pending -> PayStatus.Pending(
+ transactionId = response.transactionId,
+ error = response.lastError,
+ )
+ })
}
}
- @UiThread
- fun abortPay() {
- val ps = payStatus.value
- if (ps is PayStatus.Prepared) {
- abortProposal(ps.proposalId)
+ fun checkPayForTemplate(url: String) = scope.launch {
+ mPayStatus.value = PayStatus.Loading
+ api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) {
+ put("talerPayTemplateUri", url)
+ }.onError {
+ handleError("checkPayForTemplate", it)
+ }.onSuccess { response ->
+ mPayStatus.value = PayStatus.Checked(
+ details = response.templateDetails,
+ supportedCurrencies = response.supportedCurrencies,
+ )
}
- resetPayStatus()
}
- internal fun abortProposal(proposalId: String) = scope.launch {
- Log.i(TAG, "aborting proposal")
- api.request<Unit>("abortProposal") {
- put("proposalId", proposalId)
+ fun preparePayForTemplate(url: String, params: TemplateParams) = scope.launch {
+ mPayStatus.value = PayStatus.Loading
+ api.request("preparePayForTemplate", PreparePayResponse.serializer()) {
+ put("talerPayTemplateUri", url)
+ put("templateParams", JSONObject(BackendManager.json.encodeToString(params)))
}.onError {
- Log.e(TAG, "received error response to abortProposal")
- handleError("abortProposal", it)
- }.onSuccess {
- mPayStatus.postValue(PayStatus.None)
+ handleError("preparePayForTemplate", it)
+ }.onSuccess { response ->
+ mPayStatus.value = when (response) {
+ is PaymentPossibleResponse -> response.toPayStatusPrepared()
+ is InsufficientBalanceResponse -> InsufficientBalance(
+ contractTerms = response.contractTerms,
+ amountRaw = response.amountRaw,
+ )
+
+ is AlreadyConfirmedResponse -> AlreadyPaid(
+ transactionId = response.transactionId,
+ )
+ }
}
}
@@ -121,7 +170,7 @@ class PaymentManager(
private fun handleError(operation: String, error: TalerErrorInfo) {
Log.e(TAG, "got $operation error result $error")
- mPayStatus.value = PayStatus.Error(error.userFacingMsg)
+ mPayStatus.value = PayStatus.Pending(error = error)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt
index 7e03472..407f55f 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt
@@ -32,14 +32,14 @@ sealed class PreparePayResponse {
@Serializable
@SerialName("payment-possible")
data class PaymentPossibleResponse(
- val proposalId: String,
+ val transactionId: String,
val amountRaw: Amount,
val amountEffective: Amount,
val contractTerms: ContractTerms,
) : PreparePayResponse() {
fun toPayStatusPrepared() = PayStatus.Prepared(
contractTerms = contractTerms,
- proposalId = proposalId,
+ transactionId = transactionId,
amountRaw = amountRaw,
amountEffective = amountEffective,
)
@@ -48,7 +48,6 @@ sealed class PreparePayResponse {
@Serializable
@SerialName("insufficient-balance")
data class InsufficientBalanceResponse(
- val proposalId: String,
val amountRaw: Amount,
val contractTerms: ContractTerms,
) : PreparePayResponse()
@@ -56,13 +55,13 @@ sealed class PreparePayResponse {
@Serializable
@SerialName("already-confirmed")
data class AlreadyConfirmedResponse(
- val proposalId: String,
+ val transactionId: String,
/**
* Did the payment succeed?
*/
val paid: Boolean,
val amountRaw: Amount,
- val amountEffective: Amount,
+ val amountEffective: Amount? = null,
val contractTerms: ContractTerms,
) : PreparePayResponse()
}
@@ -71,9 +70,15 @@ sealed class PreparePayResponse {
sealed class ConfirmPayResult {
@Serializable
@SerialName("done")
- data class Done(val contractTerms: ContractTerms) : ConfirmPayResult()
+ data class Done(
+ val transactionId: String,
+ val contractTerms: ContractTerms,
+ ) : ConfirmPayResult()
@Serializable
@SerialName("pending")
- data class Pending(val lastError: TalerErrorInfo) : ConfirmPayResult()
+ data class Pending(
+ val transactionId: String,
+ val lastError: TalerErrorInfo? = null,
+ ) : ConfirmPayResult()
}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt
index 87b6387..289f0d7 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt
@@ -67,21 +67,25 @@ internal class ProductAdapter(private val listener: ProductImageClickListener) :
fun bind(product: ContractProduct) {
quantity.text = product.quantity.toString()
- if (product.image == null) {
+ val productImage = product.image
+ if (productImage == null) {
image.visibility = GONE
- } else {
- image.visibility = VISIBLE
- // product.image was validated before, so non-null below
- val match = REGEX_PRODUCT_IMAGE.matchEntire(product.image!!)!!
- val decodedString = Base64.decode(match.groups[2]!!.value, Base64.DEFAULT)
- val bitmap = decodeByteArray(decodedString, 0, decodedString.size)
- image.setImageBitmap(bitmap)
- image.setOnClickListener {
- listener.onImageClick(bitmap)
+ } else REGEX_PRODUCT_IMAGE.matchEntire(productImage)?.let { match ->
+ match.groups[2]?.value?.let { group ->
+ image.visibility = VISIBLE
+ val decodedString = Base64.decode(group, Base64.DEFAULT)
+ val bitmap = decodeByteArray(decodedString, 0, decodedString.size)
+ image.setImageBitmap(bitmap)
+ image.setOnClickListener {
+ listener.onImageClick(bitmap)
+ }
}
}
name.text = product.description
- price.text = product.totalPrice.toString()
+ price.visibility = product.totalPrice?.let {
+ price.text = it.toString()
+ VISIBLE
+ } ?: GONE
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt
index 7ed1bab..1995f9d 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt
@@ -18,23 +18,29 @@ package net.taler.wallet.payment
import android.graphics.Bitmap
import android.os.Bundle
+import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
+import kotlinx.coroutines.launch
import net.taler.common.Amount
import net.taler.common.ContractTerms
import net.taler.common.fadeIn
import net.taler.common.fadeOut
+import net.taler.common.showError
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
+import net.taler.wallet.TAG
import net.taler.wallet.databinding.FragmentPromptPaymentBinding
+import net.taler.wallet.showError
/**
* Show a payment and ask the user to accept/decline.
@@ -43,6 +49,7 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
private val model: MainViewModel by activityViewModels()
private val paymentManager by lazy { model.paymentManager }
+ private val transactionManager by lazy { model.transactionManager }
private lateinit var ui: FragmentPromptPaymentBinding
private val adapter = ProductAdapter(this)
@@ -67,7 +74,15 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
override fun onDestroy() {
super.onDestroy()
if (!requireActivity().isChangingConfigurations) {
- paymentManager.abortPay()
+ val payStatus = paymentManager.payStatus.value as? PayStatus.Prepared ?: return
+ transactionManager.abortTransaction(payStatus.transactionId) { error ->
+ Log.e(TAG, "Error abortTransaction $error")
+ if (model.devMode.value == false) {
+ showError(error.userFacingMsg)
+ } else {
+ showError(error)
+ }
+ }
}
}
@@ -83,6 +98,7 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
private fun onPaymentStatusChanged(payStatus: PayStatus?) {
when (payStatus) {
null -> {}
+ is PayStatus.Checked -> {} // does not apply, only used for templates
is PayStatus.Prepared -> {
showLoading(false)
val fees = payStatus.amountEffective - payStatus.amountRaw
@@ -91,8 +107,8 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
ui.bottom.confirmButton.setOnClickListener {
model.showProgressBar.value = true
paymentManager.confirmPay(
- payStatus.proposalId,
- payStatus.contractTerms.amount.currency
+ transactionId = payStatus.transactionId,
+ currency = payStatus.contractTerms.amount.currency,
)
ui.bottom.confirmButton.fadeOut()
ui.bottom.confirmProgressBar.fadeIn()
@@ -107,19 +123,24 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
is PayStatus.Success -> {
showLoading(false)
paymentManager.resetPayStatus()
- findNavController().navigate(R.id.action_promptPayment_to_nav_main)
- model.showTransactions(payStatus.currency)
+ navigateToTransaction(payStatus.transactionId)
Snackbar.make(requireView(), R.string.payment_initiated, LENGTH_LONG).show()
}
is PayStatus.AlreadyPaid -> {
showLoading(false)
paymentManager.resetPayStatus()
- findNavController().navigate(R.id.action_promptPayment_to_alreadyPaid)
+ navigateToTransaction(payStatus.transactionId)
+ Snackbar.make(requireView(), R.string.payment_already_paid, LENGTH_LONG).show()
}
- is PayStatus.Error -> {
+ is PayStatus.Pending -> {
showLoading(false)
- ui.details.errorView.text = getString(R.string.payment_error, payStatus.error)
- ui.details.errorView.fadeIn()
+ paymentManager.resetPayStatus()
+ navigateToTransaction(payStatus.transactionId)
+ if (payStatus.error != null && model.devMode.value == true) {
+ showError(payStatus.error)
+ } else {
+ showError(getString(R.string.payment_pending))
+ }
}
is PayStatus.None -> {
// No payment active.
@@ -154,4 +175,13 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener {
f.show(parentFragmentManager, "image")
}
+ private fun navigateToTransaction(id: String?) {
+ lifecycleScope.launch {
+ if (id != null && transactionManager.selectTransaction(id)) {
+ findNavController().navigate(R.id.action_promptPayment_to_nav_transactions_detail_payment)
+ } else {
+ findNavController().navigate(R.id.action_promptPayment_to_nav_main)
+ }
+ }
+ }
}
diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
new file mode 100644
index 0000000..beb37d9
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
@@ -0,0 +1,174 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.payment
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.common.ContractMerchant
+import net.taler.common.CurrencySpecification
+import net.taler.common.Timestamp
+import net.taler.common.toAbsoluteTime
+import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.transactions.AmountType
+import net.taler.wallet.transactions.ErrorTransactionButton
+import net.taler.wallet.transactions.TransactionAction
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
+import net.taler.wallet.transactions.TransactionAmountComposable
+import net.taler.wallet.transactions.TransactionInfo
+import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionLinkComposable
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import net.taler.wallet.transactions.TransactionPayment
+import net.taler.wallet.transactions.TransactionState
+import net.taler.wallet.transactions.TransitionsComposable
+
+@Composable
+fun TransactionPaymentComposable(
+ t: TransactionPayment,
+ devMode: Boolean,
+ spec: CurrencySpecification?,
+ onFulfill: (url: String) -> Unit,
+ onTransition: (t: TransactionAction) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ val context = LocalContext.current
+ Text(
+ modifier = Modifier.padding(16.dp),
+ text = t.timestamp.ms.toAbsoluteTime(context).toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.transaction_order_total),
+ amount = t.amountRaw.withSpec(spec),
+ amountType = AmountType.Neutral,
+ )
+
+ if (t.amountEffective > t.amountRaw) {
+ val fee = t.amountEffective - t.amountRaw
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+ }
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.transaction_paid),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+
+ if (t.posConfirmation != null) {
+ TransactionInfoComposable(
+ label = stringResource(id = R.string.payment_confirmation_code),
+ info = t.posConfirmation,
+ )
+ }
+
+ PurchaseDetails(info = t.info) {
+ onFulfill(t.info.fulfillmentUrl ?: "")
+ }
+
+ TransitionsComposable(t, devMode, onTransition)
+ if (devMode && t.error != null) {
+ ErrorTransactionButton(error = t.error)
+ }
+ }
+}
+
+@Composable
+fun PurchaseDetails(
+ info: TransactionInfo,
+ onClick: () -> Unit,
+) {
+ Column(
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ // Summary and fulfillment message
+ val text = if (info.fulfillmentMessage == null) {
+ info.summary
+ } else {
+ "${info.summary}\n\n${info.fulfillmentMessage}"
+ }
+ if (info.fulfillmentUrl != null) {
+ TransactionLinkComposable(
+ label = stringResource(id = R.string.transaction_order),
+ info = text,
+ ) { onClick() }
+ } else {
+ TransactionInfoComposable(
+ label = stringResource(id = R.string.transaction_order),
+ info = text,
+ )
+ }
+ // Order ID
+ Text(
+ stringResource(id = R.string.transaction_order_id, info.orderId),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+}
+
+@Preview
+@Composable
+fun TransactionPaymentComposablePreview() {
+ val t = TransactionPayment(
+ transactionId = "transactionId",
+ timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
+ txState = TransactionState(Pending),
+ txActions = listOf(Retry, Suspend, Abort),
+ info = TransactionInfo(
+ orderId = "123",
+ merchant = ContractMerchant(name = "Taler"),
+ summary = "Some Product that was bought and can have quite a long label",
+ fulfillmentMessage = "This is some fulfillment message",
+ fulfillmentUrl = "https://bank.demo.taler.net/",
+ products = listOf(),
+ ),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.1337"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.23"),
+ error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED),
+ )
+ TalerSurface {
+ TransactionPaymentComposable(t = t, devMode = true, spec = null, onFulfill = {}) {}
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt
new file mode 100644
index 0000000..4393e47
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt
@@ -0,0 +1,128 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.peer
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.wallet.R
+import net.taler.wallet.compose.NumericInputField
+import net.taler.wallet.compose.SelectionChip
+import net.taler.wallet.compose.TalerSurface
+
+enum class ExpirationOption(val hours: Long) {
+ DAYS_1(24),
+ DAYS_7(24 * 7),
+ DAYS_30(24 * 30),
+ CUSTOM(-1)
+}
+
+@Composable
+fun ExpirationComposable(
+ modifier: Modifier = Modifier,
+ option: ExpirationOption,
+ hours: Long,
+ onOptionChange: (ExpirationOption) -> Unit,
+ onHoursChange: (Long) -> Unit,
+) {
+ val options = listOf(
+ ExpirationOption.DAYS_1 to stringResource(R.string.send_peer_expiration_1d),
+ ExpirationOption.DAYS_7 to stringResource(R.string.send_peer_expiration_7d),
+ ExpirationOption.DAYS_30 to stringResource(R.string.send_peer_expiration_30d),
+ ExpirationOption.CUSTOM to stringResource(R.string.send_peer_expiration_custom),
+ )
+ Column(
+ modifier = modifier,
+ ) {
+ LazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ items(items = options, key = { it.first }) {
+ SelectionChip(
+ label = { Text(it.second) },
+ modifier = Modifier.padding(horizontal = 4.dp),
+ selected = it.first == option,
+ value = it.first,
+ onSelected = { o ->
+ onOptionChange(o)
+ if (o != ExpirationOption.CUSTOM) {
+ onHoursChange(o.hours)
+ }
+ },
+ )
+ }
+ }
+
+ if (option == ExpirationOption.CUSTOM) {
+ val d = hours / 24L
+ val h = hours - d * 24L
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ NumericInputField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(end = 4.dp),
+ value = d,
+ onValueChange = {
+ onHoursChange(it * 24 + h)
+ },
+ label = { Text(stringResource(R.string.send_peer_expiration_days)) },
+ maxValue = 365,
+ )
+ NumericInputField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(start = 4.dp),
+ value = h,
+ onValueChange = {
+ onHoursChange(d * 24 + it)
+ },
+ label = { Text(stringResource(R.string.send_peer_expiration_hours)) },
+ maxValue = 23,
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ExpirationComposablePreview() {
+ TalerSurface {
+ var option = ExpirationOption.CUSTOM
+ var hours = 25L
+ ExpirationComposable(
+ option = option,
+ hours = hours,
+ onOptionChange = { option = it }
+ ) { hours = it }
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt
index 70f36a2..609629e 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt
@@ -27,13 +27,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.ButtonDefaults
-import androidx.compose.material.Card
-import androidx.compose.material.CircularProgressIndicator
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
@@ -48,20 +48,25 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.taler.common.Amount
import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode.WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE
+import net.taler.wallet.backend.TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE
import net.taler.wallet.backend.TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED
import net.taler.wallet.backend.TalerErrorInfo
data class IncomingData(
+ val isCredit: Boolean,
@StringRes val intro: Int,
@StringRes val button: Int,
)
val incomingPush = IncomingData(
+ isCredit = true,
intro = R.string.receive_peer_payment_intro,
button = R.string.receive_peer_payment_title,
)
val incomingPull = IncomingData(
+ isCredit = false,
intro = R.string.pay_peer_intro,
button = R.string.payment_button_confirm,
)
@@ -86,12 +91,12 @@ fun IncomingComposable(
)
when (val s = state.value) {
IncomingChecking -> PeerPullCheckingComposable()
- is IncomingTerms -> PeerPullTermsComposable(s, onAccept, data)
is IncomingAccepting -> PeerPullTermsComposable(s, onAccept, data)
+ is IncomingTerms -> PeerPullTermsComposable(s, onAccept, data)
+ is IncomingError -> PeerPullErrorComposable(s)
IncomingAccepted -> {
// we navigate away, don't show anything
}
- is IncomingError -> PeerPullErrorComposable(s)
}
}
}
@@ -116,36 +121,50 @@ fun ColumnScope.PeerPullTermsComposable(
.padding(16.dp)
.align(CenterHorizontally),
text = terms.contractTerms.summary,
- style = MaterialTheme.typography.h5,
+ style = MaterialTheme.typography.headlineSmall,
)
Spacer(modifier = Modifier.weight(1f))
Card(modifier = Modifier.fillMaxWidth()) {
Column(
- modifier = Modifier.padding(8.dp)
+ modifier = Modifier
+ .padding(8.dp)
+ .fillMaxWidth()
) {
Row(
modifier = Modifier.align(End),
) {
Text(
- text = stringResource(id = R.string.payment_label_amount_total),
- style = MaterialTheme.typography.body1,
+ text = stringResource(id = R.string.amount_total_label),
+ style = MaterialTheme.typography.bodyLarge,
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = terms.contractTerms.amount.toString(),
- style = MaterialTheme.typography.body1,
+ style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
)
}
- val fee =
- Amount.zero(terms.amount.currency) // terms.amount - terms.contractTerms.amount
- if (!fee.isZero()) {
+ // this gets used for credit and debit, so fee calculation differs
+ val fee = if (data.isCredit && terms.amountRaw > terms.amountEffective) {
+ terms.amountRaw - terms.amountEffective
+ } else if (terms.amountEffective > terms.amountRaw) {
+ terms.amountEffective - terms.amountRaw
+ } else null
+
+ if (fee != null) {
+ val feeStr = if (data.isCredit) {
+ stringResource(R.string.amount_negative, fee)
+ } else {
+ stringResource(R.string.amount_positive, fee)
+ }
Text(
modifier = Modifier.align(End),
- text = stringResource(id = R.string.payment_fee, fee),
- style = MaterialTheme.typography.body1,
+ text = feeStr,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
)
}
+
if (terms is IncomingAccepting) {
CircularProgressIndicator(
modifier = Modifier
@@ -158,7 +177,7 @@ fun ColumnScope.PeerPullTermsComposable(
.align(End)
.padding(top = 8.dp),
colors = ButtonDefaults.buttonColors(
- backgroundColor = colorResource(R.color.green),
+ containerColor = colorResource(R.color.green),
contentColor = Color.White,
),
onClick = { onAccept(terms) },
@@ -174,13 +193,19 @@ fun ColumnScope.PeerPullTermsComposable(
@Composable
fun ColumnScope.PeerPullErrorComposable(s: IncomingError) {
+ val message = when (s.info.code) {
+ WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE -> stringResource(R.string.payment_balance_insufficient)
+ WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE -> stringResource(R.string.payment_balance_insufficient)
+ else -> s.info.userFacingMsg
+ }
+
Text(
modifier = Modifier
.align(CenterHorizontally)
.padding(horizontal = 32.dp),
- text = s.info.userFacingMsg,
- style = MaterialTheme.typography.h5,
- color = colorResource(id = R.color.red),
+ text = message,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.error,
)
}
@@ -199,10 +224,11 @@ fun PeerPullCheckingPreview() {
fun PeerPullTermsPreview() {
Surface {
val terms = IncomingTerms(
- amount = Amount.fromDouble("TESTKUDOS", 42.23),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.423"),
contractTerms = PeerContractTerms(
summary = "This is a long test summary that can be more than one line long for sure",
- amount = Amount.fromDouble("TESTKUDOS", 23.42),
+ amount = Amount.fromString("TESTKUDOS", "23.42"),
),
id = "ID123",
)
@@ -218,10 +244,11 @@ fun PeerPullTermsPreview() {
fun PeerPullAcceptingPreview() {
Surface {
val terms = IncomingTerms(
- amount = Amount.fromDouble("TESTKUDOS", 42.23),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.123"),
contractTerms = PeerContractTerms(
summary = "This is a long test summary that can be more than one line long for sure",
- amount = Amount.fromDouble("TESTKUDOS", 23.42),
+ amount = Amount.fromString("TESTKUDOS", "23.42"),
),
id = "ID123",
)
@@ -237,7 +264,11 @@ fun PeerPullAcceptingPreview() {
fun PeerPullPayErrorPreview() {
Surface {
@SuppressLint("UnrememberedMutableState")
- val s = mutableStateOf(IncomingError(TalerErrorInfo(WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "msg")))
+ val s = mutableStateOf(
+ IncomingError(
+ info = TalerErrorInfo(WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "msg"),
+ )
+ )
IncomingComposable(s, incomingPush) {}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt
index 6a74c30..df71c72 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt
@@ -25,10 +25,12 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
+import net.taler.common.showError
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.compose.collectAsStateLifecycleAware
+import net.taler.wallet.showError
class IncomingPullPaymentFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
@@ -43,6 +45,12 @@ class IncomingPullPaymentFragment : Fragment() {
peerManager.incomingPullState.collect {
if (it is IncomingAccepted) {
findNavController().navigate(R.id.action_promptPullPayment_to_nav_main)
+ } else if (it is IncomingError) {
+ if (model.devMode.value == true) {
+ showError(it.info)
+ } else {
+ showError(it.info.userFacingMsg)
+ }
}
}
}
@@ -51,7 +59,7 @@ class IncomingPullPaymentFragment : Fragment() {
TalerSurface {
val state = peerManager.incomingPullState.collectAsStateLifecycleAware()
IncomingComposable(state, incomingPull) { terms ->
- peerManager.acceptPeerPullPayment(terms)
+ peerManager.confirmPeerPullDebit(terms)
}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt
index 56734e3..ced2b82 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt
@@ -25,10 +25,12 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
+import net.taler.common.showError
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.compose.collectAsStateLifecycleAware
+import net.taler.wallet.showError
class IncomingPushPaymentFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
@@ -43,6 +45,12 @@ class IncomingPushPaymentFragment : Fragment() {
peerManager.incomingPushState.collect {
if (it is IncomingAccepted) {
findNavController().navigate(R.id.action_promptPushPayment_to_nav_main)
+ } else if (it is IncomingError) {
+ if (model.devMode.value == true) {
+ showError(it.info)
+ } else {
+ showError(it.info.userFacingMsg)
+ }
}
}
}
@@ -51,7 +59,7 @@ class IncomingPushPaymentFragment : Fragment() {
TalerSurface {
val state = peerManager.incomingPushState.collectAsStateLifecycleAware()
IncomingComposable(state, incomingPush) { terms ->
- peerManager.acceptPeerPushPayment(terms)
+ peerManager.confirmPeerPushCredit(terms)
}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt
index 7ca38c4..cd5b5dd 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt
@@ -23,13 +23,14 @@ import net.taler.wallet.backend.TalerErrorInfo
sealed class IncomingState
object IncomingChecking : IncomingState()
open class IncomingTerms(
- val amount: Amount,
+ val amountRaw: Amount,
+ val amountEffective: Amount,
val contractTerms: PeerContractTerms,
val id: String,
) : IncomingState()
class IncomingAccepting(s: IncomingTerms) :
- IncomingTerms(s.amount, s.contractTerms, s.id)
+ IncomingTerms(s.amountRaw, s.amountEffective, s.contractTerms, s.id)
object IncomingAccepted : IncomingState()
data class IncomingError(
@@ -43,15 +44,17 @@ data class PeerContractTerms(
)
@Serializable
-data class CheckPeerPullPaymentResponse(
- val amount: Amount,
+data class PreparePeerPullDebitResponse(
val contractTerms: PeerContractTerms,
- val peerPullPaymentIncomingId: String,
+ val amountRaw: Amount,
+ val amountEffective: Amount,
+ val transactionId: String,
)
@Serializable
-data class CheckPeerPushPaymentResponse(
- val amount: Amount,
+data class PreparePeerPushCreditResponse(
val contractTerms: PeerContractTerms,
- val peerPushPaymentIncomingId: String,
+ val amountRaw: Amount,
+ val amountEffective: Amount,
+ val transactionId: String,
)
diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt
new file mode 100644
index 0000000..f3d569f
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt
@@ -0,0 +1,280 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2022 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.peer
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment.Companion.Center
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.serialization.json.JsonPrimitive
+import net.taler.common.Amount
+import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.cleanExchange
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.exchanges.ExchangeItem
+import net.taler.wallet.transactions.AmountType
+import net.taler.wallet.transactions.TransactionAmountComposable
+import net.taler.wallet.transactions.TransactionInfoComposable
+import kotlin.random.Random
+
+@Composable
+fun OutgoingPullComposable(
+ amount: Amount,
+ state: OutgoingState,
+ onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchange: ExchangeItem) -> Unit,
+ onClose: () -> Unit,
+) {
+ when(state) {
+ is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> PeerCreatingComposable()
+ is OutgoingIntro, is OutgoingChecked -> OutgoingPullIntroComposable(
+ amount = amount,
+ state = state,
+ onCreateInvoice = onCreateInvoice,
+ )
+ is OutgoingError -> PeerErrorComposable(state, onClose)
+ }
+}
+
+@Composable
+fun PeerCreatingComposable() {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .padding(32.dp)
+ .align(Center),
+ )
+ }
+}
+
+@Composable
+fun OutgoingPullIntroComposable(
+ amount: Amount,
+ state: OutgoingState,
+ onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchange: ExchangeItem) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .verticalScroll(scrollState),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ var subject by rememberSaveable { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
+
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ singleLine = true,
+ value = subject,
+ onValueChange = { input ->
+ if (input.length <= MAX_LENGTH_SUBJECT)
+ subject = input.replace('\n', ' ')
+ },
+ isError = subject.isBlank(),
+ label = {
+ Text(
+ stringResource(R.string.send_peer_purpose),
+ color = if (subject.isBlank()) {
+ MaterialTheme.colorScheme.error
+ } else Color.Unspecified,
+ )
+ }
+ )
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 5.dp),
+ color = if (subject.isBlank()) MaterialTheme.colorScheme.error else Color.Unspecified,
+ text = stringResource(R.string.char_count, subject.length, MAX_LENGTH_SUBJECT),
+ textAlign = TextAlign.End,
+ )
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_chosen),
+ amount = amount,
+ amountType = AmountType.Positive,
+ )
+
+ if (state is OutgoingChecked && state.amountRaw > state.amountEffective) {
+ val fee = state.amountRaw - state.amountEffective
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(amount.spec),
+ amountType = AmountType.Negative,
+ )
+ }
+
+ val exchangeItem = (state as? OutgoingChecked)?.exchangeItem
+ TransactionInfoComposable(
+ label = stringResource(id = R.string.withdraw_exchange),
+ info = if (exchangeItem == null) "" else cleanExchange(exchangeItem.exchangeBaseUrl),
+ )
+
+ Text(
+ modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
+ text = stringResource(R.string.send_peer_expiration_period),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+
+ var option by rememberSaveable { mutableStateOf(DEFAULT_EXPIRY) }
+ var hours by rememberSaveable { mutableStateOf(DEFAULT_EXPIRY.hours) }
+ ExpirationComposable(
+ modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
+ option = option,
+ hours = hours,
+ onOptionChange = { option = it }
+ ) { hours = it }
+
+ Button(
+ modifier = Modifier.padding(16.dp),
+ enabled = subject.isNotBlank() && state is OutgoingChecked,
+ onClick = {
+ onCreateInvoice(
+ amount,
+ subject,
+ hours,
+ exchangeItem ?: error("clickable without exchange")
+ )
+ },
+ ) {
+ Text(text = stringResource(R.string.receive_peer_create_button))
+ }
+ }
+}
+
+@Composable
+fun PeerErrorComposable(state: OutgoingError, onClose: () -> Unit) {
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ Text(
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyLarge,
+ text = state.info.userFacingMsg,
+ )
+
+ Button(
+ modifier = Modifier.padding(16.dp),
+ onClick = onClose,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ ) {
+ Text(text = stringResource(R.string.close))
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PeerPullComposableCreatingPreview() {
+ TalerSurface {
+ OutgoingPullComposable(
+ amount = Amount.fromString("TESTKUDOS", "42.23"),
+ state = OutgoingCreating,
+ onCreateInvoice = { _, _, _, _ -> },
+ onClose = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PeerPullComposableCheckingPreview() {
+ TalerSurface {
+ OutgoingPullComposable(
+ amount = Amount.fromString("TESTKUDOS", "42.23"),
+ state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking,
+ onCreateInvoice = { _, _, _, _ -> },
+ onClose = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PeerPullComposableCheckedPreview() {
+ TalerSurface {
+ val amountRaw = Amount.fromString("TESTKUDOS", "42.42")
+ val amountEffective = Amount.fromString("TESTKUDOS", "42.23")
+ val exchangeItem = ExchangeItem("https://example.org", "TESTKUDOS", emptyList())
+ OutgoingPullComposable(
+ amount = Amount.fromString("TESTKUDOS", "42.23"),
+ state = OutgoingChecked(amountRaw, amountEffective, exchangeItem),
+ onCreateInvoice = { _, _, _, _ -> },
+ onClose = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PeerPullComposableErrorPreview() {
+ TalerSurface {
+ val json = mapOf("foo" to JsonPrimitive("bar"))
+ val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json))
+ OutgoingPullComposable(
+ amount = Amount.fromString("TESTKUDOS", "42.23"),
+ state = state,
+ onCreateInvoice = { _, _, _, _ -> },
+ onClose = {},
+ )
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt
index b9634d5..8f2fb96 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt
@@ -23,18 +23,24 @@ import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
-import androidx.navigation.findNavController
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import kotlinx.coroutines.launch
import net.taler.common.Amount
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.compose.collectAsStateLifecycleAware
import net.taler.wallet.exchanges.ExchangeItem
+import net.taler.wallet.showError
class OutgoingPullFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
- private val exchangeManager get() = model.exchangeManager
private val peerManager get() = model.peerManager
+ private val transactionManager get() = model.transactionManager
+ private val balanceManager get() = model.balanceManager
override fun onCreateView(
inflater: LayoutInflater,
@@ -44,23 +50,41 @@ class OutgoingPullFragment : Fragment() {
val amount = arguments?.getString("amount")?.let {
Amount.fromJSONString(it)
} ?: error("no amount passed")
- val exchangeFlow = exchangeManager.findExchangeForCurrency(amount.currency)
+ val scopeInfo = transactionManager.selectedScope
+ val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) }
+
return ComposeView(requireContext()).apply {
setContent {
TalerSurface {
- val state = peerManager.pullState.collectAsStateLifecycleAware()
- if (state.value is OutgoingIntro) {
- val exchangeState =
- exchangeFlow.collectAsStateLifecycleAware(initial = null)
- OutgoingPullIntroComposable(
- amount = amount,
- exchangeState = exchangeState,
- onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice,
- )
- } else {
- OutgoingPullResultComposable(state.value) {
- findNavController().popBackStack()
+ val state = peerManager.pullState.collectAsStateLifecycleAware().value
+ OutgoingPullComposable(
+ amount = amount.withSpec(spec),
+ state = state,
+ onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice,
+ onClose = {
+ findNavController().navigate(R.id.action_nav_peer_pull_to_nav_main)
}
+ )
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ peerManager.pullState.collect {
+ if (it is OutgoingResponse) {
+ if (transactionManager.selectTransaction(it.transactionId)) {
+ findNavController().navigate(R.id.action_nav_peer_pull_to_nav_transactions_detail_peer)
+ } else {
+ findNavController().navigate(R.id.action_nav_peer_pull_to_nav_main)
+ }
+ }
+
+ if (it is OutgoingError && model.devMode.value == true) {
+ showError(it.info)
}
}
}
@@ -77,7 +101,7 @@ class OutgoingPullFragment : Fragment() {
if (!requireActivity().isChangingConfigurations) peerManager.resetPullPayment()
}
- private fun onCreateInvoice(amount: Amount, summary: String, exchange: ExchangeItem) {
- peerManager.initiatePullPayment(amount, summary, exchange)
+ private fun onCreateInvoice(amount: Amount, summary: String, hours: Long, exchange: ExchangeItem) {
+ peerManager.initiatePeerPullCredit(amount, summary, hours, exchange)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt
deleted file mode 100644
index 9af3f5d..0000000
--- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt
+++ /dev/null
@@ -1,141 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2022 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.peer
-
-import android.annotation.SuppressLint
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.OutlinedTextField
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.State
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment.Companion.CenterHorizontally
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import net.taler.common.Amount
-import net.taler.wallet.R
-import net.taler.wallet.cleanExchange
-import net.taler.wallet.exchanges.ExchangeItem
-
-@Composable
-fun OutgoingPullIntroComposable(
- amount: Amount,
- exchangeState: State<ExchangeItem?>,
- onCreateInvoice: (amount: Amount, subject: String, exchange: ExchangeItem) -> Unit,
-) {
- val scrollState = rememberScrollState()
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(scrollState),
- horizontalAlignment = CenterHorizontally,
- ) {
- var subject by rememberSaveable { mutableStateOf("") }
- val focusRequester = remember { FocusRequester() }
- val exchangeItem = exchangeState.value
- OutlinedTextField(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 16.dp, start = 16.dp, end = 16.dp)
- .focusRequester(focusRequester),
- singleLine = true,
- value = subject,
- onValueChange = { input ->
- if (input.length <= MAX_LENGTH_SUBJECT)
- subject = input
- },
- isError = subject.isBlank(),
- label = {
- Text(
- stringResource(R.string.send_peer_purpose),
- color = if (subject.isBlank()) {
- colorResource(R.color.red)
- } else Color.Unspecified,
- )
- }
- )
- LaunchedEffect(Unit) {
- focusRequester.requestFocus()
- }
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 5.dp, end = 16.dp),
- color = if (subject.isBlank()) colorResource(R.color.red) else Color.Unspecified,
- text = stringResource(R.string.char_count, subject.length, MAX_LENGTH_SUBJECT),
- textAlign = TextAlign.End,
- )
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- text = stringResource(id = R.string.amount_chosen),
- )
- Text(
- modifier = Modifier.padding(16.dp),
- fontSize = 24.sp,
- color = colorResource(R.color.green),
- text = amount.toString(),
- )
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- text = stringResource(R.string.withdraw_exchange),
- )
- Text(
- modifier = Modifier.padding(16.dp),
- fontSize = 24.sp,
- text = if (exchangeItem == null) "" else cleanExchange(exchangeItem.exchangeBaseUrl),
- )
- Button(
- modifier = Modifier.padding(16.dp),
- enabled = subject.isNotBlank() && exchangeItem != null,
- onClick = {
- onCreateInvoice(amount, subject, exchangeItem ?: error("clickable without exchange"))
- },
- ) {
- Text(text = stringResource(R.string.receive_peer_create_button))
- }
- }
-}
-
-@Preview
-@Composable
-fun PreviewReceiveFundsIntro() {
- Surface {
- @SuppressLint("UnrememberedMutableState")
- val exchangeFlow =
- mutableStateOf(ExchangeItem("https://example.org", "TESTKUDOS", emptyList()))
- OutgoingPullIntroComposable(Amount.fromDouble("TESTKUDOS", 42.23), exchangeFlow) { _, _, _ -> }
- }
-}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullResultComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullResultComposable.kt
deleted file mode 100644
index 84d9415..0000000
--- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullResultComposable.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2022 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.peer
-
-import android.content.res.Configuration.UI_MODE_NIGHT_YES
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.CircularProgressIndicator
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment.Companion.CenterHorizontally
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import net.taler.common.QrCodeManager
-import net.taler.wallet.R
-import net.taler.wallet.backend.TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED
-import net.taler.wallet.backend.TalerErrorInfo
-import net.taler.wallet.compose.QrCodeUriComposable
-import net.taler.wallet.compose.TalerSurface
-import net.taler.wallet.compose.getQrCodeSize
-import org.json.JSONObject
-
-@Composable
-fun OutgoingPullResultComposable(state: OutgoingState, onClose: () -> Unit) {
- val scrollState = rememberScrollState()
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(scrollState),
- ) {
- Text(
- modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
- style = MaterialTheme.typography.h6,
- text = stringResource(id = R.string.receive_peer_invoice_instruction),
- )
- when (state) {
- OutgoingIntro -> error("Result composable with PullPaymentIntro")
- is OutgoingCreating -> PeerPullCreatingComposable()
- is OutgoingResponse -> PeerPullResponseComposable(state)
- is OutgoingError -> PeerPullErrorComposable(state)
- }
- Button(modifier = Modifier
- .padding(16.dp)
- .align(CenterHorizontally),
- onClick = onClose) {
- Text(text = stringResource(R.string.close))
- }
- }
-}
-
-@Composable
-private fun ColumnScope.PeerPullCreatingComposable() {
- val qrCodeSize = getQrCodeSize()
- CircularProgressIndicator(
- modifier = Modifier
- .padding(32.dp)
- .size(qrCodeSize)
- .align(CenterHorizontally),
- )
-}
-
-@Composable
-private fun ColumnScope.PeerPullResponseComposable(state: OutgoingResponse) {
- QrCodeUriComposable(
- talerUri = state.talerUri,
- clipBoardLabel = "Invoice",
- ) {
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- style = MaterialTheme.typography.body1,
- text = stringResource(id = R.string.receive_peer_invoice_uri),
- )
- }
-}
-
-@Composable
-private fun ColumnScope.PeerPullErrorComposable(state: OutgoingError) {
- Text(
- modifier = Modifier
- .align(CenterHorizontally)
- .padding(16.dp),
- color = colorResource(R.color.red),
- style = MaterialTheme.typography.body1,
- text = state.info.userFacingMsg,
- )
-}
-
-@Preview
-@Composable
-fun PeerPullCreatingPreview() {
- Surface {
- OutgoingPullResultComposable(OutgoingCreating) {}
- }
-}
-
-@Preview(uiMode = UI_MODE_NIGHT_YES)
-@Composable
-fun PeerPullResponsePreview() {
- TalerSurface {
- val talerUri = "https://example.org/foo/bar/can/be/very/long/url/so/fit/it/on/screen"
- val response = OutgoingResponse(talerUri, QrCodeManager.makeQrCode(talerUri))
- OutgoingPullResultComposable(response) {}
- }
-}
-
-@Preview(widthDp = 720, uiMode = UI_MODE_NIGHT_YES)
-@Composable
-fun PeerPullResponseLandscapePreview() {
- TalerSurface {
- val talerUri = "https://example.org/foo/bar/can/be/very/long/url/so/fit/it/on/screen"
- val response = OutgoingResponse(talerUri, QrCodeManager.makeQrCode(talerUri))
- OutgoingPullResultComposable(response) {}
- }
-}
-
-@Preview
-@Composable
-fun PeerPullErrorPreview() {
- Surface {
- val json = JSONObject().apply { put("foo", "bar") }
- val response = OutgoingError(TalerErrorInfo(WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json))
- OutgoingPullResultComposable(response) {}
- }
-}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt
new file mode 100644
index 0000000..7eba733
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt
@@ -0,0 +1,218 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2022 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.peer
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.serialization.json.JsonPrimitive
+import net.taler.common.Amount
+import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.compose.TalerSurface
+import kotlin.random.Random
+
+@Composable
+fun OutgoingPushComposable(
+ state: OutgoingState,
+ amount: Amount,
+ onSend: (amount: Amount, summary: String, hours: Long) -> Unit,
+ onClose: () -> Unit,
+) {
+ when(state) {
+ is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> PeerCreatingComposable()
+ is OutgoingIntro, is OutgoingChecked -> OutgoingPushIntroComposable(
+ amount = amount,
+ state = state,
+ onSend = onSend,
+ )
+ is OutgoingError -> PeerErrorComposable(state, onClose)
+ }
+}
+
+@Composable
+fun OutgoingPushIntroComposable(
+ state: OutgoingState,
+ amount: Amount,
+ onSend: (amount: Amount, summary: String, hours: Long) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .verticalScroll(scrollState),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ Text(
+ modifier = Modifier.padding(vertical = 16.dp),
+ text = amount.toString(),
+ softWrap = false,
+ style = MaterialTheme.typography.titleLarge,
+ )
+
+ if (state is OutgoingChecked && state.amountEffective > state.amountRaw) {
+ val fee = state.amountEffective - state.amountRaw
+ Text(
+ modifier = Modifier.padding(vertical = 16.dp),
+ text = stringResource(id = R.string.payment_fee, fee.withSpec(amount.spec)),
+ softWrap = false,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ var subject by rememberSaveable { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ singleLine = true,
+ value = subject,
+ onValueChange = { input ->
+ if (input.length <= MAX_LENGTH_SUBJECT)
+ subject = input.replace('\n', ' ')
+ },
+ isError = subject.isBlank(),
+ label = {
+ Text(
+ stringResource(R.string.send_peer_purpose),
+ color = if (subject.isBlank()) {
+ MaterialTheme.colorScheme.error
+ } else Color.Unspecified,
+ )
+ }
+ )
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 5.dp),
+ color = if (subject.isBlank()) MaterialTheme.colorScheme.error else Color.Unspecified,
+ text = stringResource(R.string.char_count, subject.length, MAX_LENGTH_SUBJECT),
+ textAlign = TextAlign.End,
+ )
+
+ Text(
+ modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
+ text = stringResource(R.string.send_peer_expiration_period),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+
+ var option by rememberSaveable { mutableStateOf(DEFAULT_EXPIRY) }
+ var hours by rememberSaveable { mutableLongStateOf(DEFAULT_EXPIRY.hours) }
+ ExpirationComposable(
+ modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
+ option = option,
+ hours = hours,
+ onOptionChange = { option = it }
+ ) { hours = it }
+
+ Button(
+ enabled = state is OutgoingChecked && subject.isNotBlank(),
+ onClick = { onSend(amount, subject, hours) },
+ ) {
+ Text(text = stringResource(R.string.send_peer_create_button))
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PeerPushComposableCreatingPreview() {
+ TalerSurface {
+ OutgoingPushComposable(
+ amount = Amount.fromString("TESTKUDOS", "42.23"),
+ state = OutgoingCreating,
+ onSend = { _, _, _ -> },
+ onClose = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PeerPushComposableCheckingPreview() {
+ TalerSurface {
+ val state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking
+ OutgoingPushComposable(
+ state = state,
+ amount = Amount.fromString("TESTKUDOS", "42.23"),
+ onSend = { _, _, _ -> },
+ onClose = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PeerPushComposableCheckedPreview() {
+ TalerSurface {
+ val amountEffective = Amount.fromString("TESTKUDOS", "42.42")
+ val amountRaw = Amount.fromString("TESTKUDOS", "42.23")
+ val state = OutgoingChecked(amountRaw, amountEffective)
+ OutgoingPushComposable(
+ state = state,
+ amount = amountEffective,
+ onSend = { _, _, _ -> },
+ onClose = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PeerPushComposableErrorPreview() {
+ TalerSurface {
+ val json = mapOf("foo" to JsonPrimitive("bar"))
+ val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json))
+ OutgoingPushComposable(
+ amount = Amount.fromString("TESTKUDOS", "42.23"),
+ state = state,
+ onSend = { _, _, _ -> },
+ onClose = {},
+ )
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt
index b110876..01fb566 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt
@@ -20,19 +20,35 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.activity.OnBackPressedCallback
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.findNavController
+import androidx.navigation.fragment.findNavController
+import kotlinx.coroutines.launch
import net.taler.common.Amount
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.compose.collectAsStateLifecycleAware
+import net.taler.wallet.showError
class OutgoingPushFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
private val peerManager get() = model.peerManager
+ private val transactionManager get() = model.transactionManager
+ private val balanceManager get() = model.balanceManager
+
+ // hacky way to change back action until we have navigation for compose
+ private val backPressedCallback = object : OnBackPressedCallback(false) {
+ override fun handleOnBackPressed() {
+ findNavController().navigate(R.id.action_nav_peer_push_to_nav_main)
+ }
+ }
override fun onCreateView(
inflater: LayoutInflater,
@@ -42,20 +58,49 @@ class OutgoingPushFragment : Fragment() {
val amount = arguments?.getString("amount")?.let {
Amount.fromJSONString(it)
} ?: error("no amount passed")
+ val scopeInfo = transactionManager.selectedScope
+ val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) }
+
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner, backPressedCallback
+ )
+
return ComposeView(requireContext()).apply {
setContent {
TalerSurface {
- val state = peerManager.pushState.collectAsStateLifecycleAware()
- if (state.value is OutgoingIntro) {
- OutgoingPushIntroComposable(
- amount = amount,
- onSend = this@OutgoingPushFragment::onSend,
- )
- } else {
- OutgoingPushResultComposable(state.value) {
- findNavController().popBackStack()
+ val state = peerManager.pushState.collectAsStateLifecycleAware().value
+ OutgoingPushComposable(
+ amount = amount.withSpec(spec),
+ state = state,
+ onSend = this@OutgoingPushFragment::onSend,
+ onClose = {
+ findNavController().navigate(R.id.action_nav_peer_pull_to_nav_main)
+ }
+ )
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ peerManager.pushState.collect {
+ if (it is OutgoingResponse) {
+ if (transactionManager.selectTransaction(it.transactionId)) {
+ findNavController().navigate(R.id.action_nav_peer_push_to_nav_transactions_detail_peer)
+ } else {
+ findNavController().navigate(R.id.action_nav_peer_push_to_nav_main)
}
}
+
+ if (it is OutgoingError && model.devMode.value == true) {
+ showError(it.info)
+ }
+
+ // Disable back navigation when tx is being created
+ backPressedCallback.isEnabled = it !is OutgoingCreating
}
}
}
@@ -63,7 +108,7 @@ class OutgoingPushFragment : Fragment() {
override fun onStart() {
super.onStart()
- activity?.setTitle(R.string.receive_peer_title)
+ activity?.setTitle(R.string.send_peer_title)
}
override fun onDestroy() {
@@ -71,7 +116,7 @@ class OutgoingPushFragment : Fragment() {
if (!requireActivity().isChangingConfigurations) peerManager.resetPushPayment()
}
- private fun onSend(amount: Amount, summary: String) {
- peerManager.initiatePeerPushPayment(amount, summary)
+ private fun onSend(amount: Amount, summary: String, hours: Long) {
+ peerManager.initiatePeerPushDebit(amount, summary, hours)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushIntroComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushIntroComposable.kt
deleted file mode 100644
index a77cfb7..0000000
--- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushIntroComposable.kt
+++ /dev/null
@@ -1,123 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2022 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.peer
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.OutlinedTextField
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-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.graphics.Color
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import net.taler.common.Amount
-import net.taler.wallet.R
-
-@Composable
-fun OutgoingPushIntroComposable(
- amount: Amount,
- onSend: (amount: Amount, summary: String) -> Unit,
-) {
- val scrollState = rememberScrollState()
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(scrollState),
- horizontalAlignment = CenterHorizontally,
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .padding(16.dp),
- ) {
- Text(
- modifier = Modifier,
- text = amount.toString(),
- softWrap = false,
- style = MaterialTheme.typography.h6,
- )
- }
-
- var subject by rememberSaveable { mutableStateOf("") }
- OutlinedTextField(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- singleLine = true,
- value = subject,
- onValueChange = { input ->
- if (input.length <= MAX_LENGTH_SUBJECT)
- subject = input
- },
- isError = subject.isBlank(),
- label = {
- Text(
- stringResource(R.string.send_peer_purpose),
- color = if (subject.isBlank()) {
- colorResource(R.color.red)
- } else Color.Unspecified,
- )
- }
- )
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 5.dp, end = 16.dp),
- color = if (subject.isBlank()) colorResource(R.color.red) else Color.Unspecified,
- text = stringResource(R.string.char_count, subject.length, MAX_LENGTH_SUBJECT),
- textAlign = TextAlign.End,
- )
- Text(
- modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
- text = stringResource(R.string.send_peer_warning),
- )
- Button(
- modifier = Modifier.padding(16.dp),
- enabled = subject.isNotBlank(),
- onClick = {
- onSend(amount, subject)
- },
- ) {
- Text(text = stringResource(R.string.send_peer_create_button))
- }
- }
-}
-
-@Preview
-@Composable
-fun PeerPushIntroComposablePreview() {
- Surface {
- OutgoingPushIntroComposable(Amount.fromDouble("TESTKUDOS", 42.23)) { _, _ -> }
- }
-}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushResultComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushResultComposable.kt
deleted file mode 100644
index 27b04c8..0000000
--- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushResultComposable.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2022 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.peer
-
-import android.content.res.Configuration.UI_MODE_NIGHT_YES
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.CircularProgressIndicator
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment.Companion.CenterHorizontally
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import net.taler.common.QrCodeManager
-import net.taler.wallet.R
-import net.taler.wallet.backend.TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED
-import net.taler.wallet.backend.TalerErrorInfo
-import net.taler.wallet.compose.QrCodeUriComposable
-import net.taler.wallet.compose.TalerSurface
-import net.taler.wallet.compose.getQrCodeSize
-import org.json.JSONObject
-
-@Composable
-fun OutgoingPushResultComposable(state: OutgoingState, onClose: () -> Unit) {
- val scrollState = rememberScrollState()
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(scrollState),
- ) {
- Text(
- modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
- style = MaterialTheme.typography.h6,
- text = stringResource(id = R.string.send_peer_payment_instruction),
- )
- when (state) {
- OutgoingIntro -> error("Result composable with PullPaymentIntro")
- is OutgoingCreating -> PeerPushCreatingComposable()
- is OutgoingResponse -> PeerPushResponseComposable(state)
- is OutgoingError -> PeerPushErrorComposable(state)
- }
- Button(modifier = Modifier
- .padding(16.dp)
- .align(CenterHorizontally),
- onClick = onClose) {
- Text(text = stringResource(R.string.close))
- }
- }
-}
-
-@Composable
-private fun ColumnScope.PeerPushCreatingComposable() {
- val qrCodeSize = getQrCodeSize()
- CircularProgressIndicator(
- modifier = Modifier
- .padding(32.dp)
- .size(qrCodeSize)
- .align(CenterHorizontally),
- )
-}
-
-@Composable
-private fun ColumnScope.PeerPushResponseComposable(state: OutgoingResponse) {
- QrCodeUriComposable(
- talerUri = state.talerUri,
- clipBoardLabel = "Invoice",
- ) {
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- style = MaterialTheme.typography.body1,
- text = stringResource(id = R.string.receive_peer_invoice_uri),
- )
- }
-}
-
-@Composable
-private fun ColumnScope.PeerPushErrorComposable(state: OutgoingError) {
- Text(
- modifier = Modifier
- .align(CenterHorizontally)
- .padding(16.dp),
- color = colorResource(R.color.red),
- style = MaterialTheme.typography.body1,
- text = state.info.userFacingMsg,
- )
-}
-
-@Preview
-@Composable
-fun PeerPushCreatingPreview() {
- Surface {
- OutgoingPushResultComposable(OutgoingCreating) {}
- }
-}
-
-@Preview(uiMode = UI_MODE_NIGHT_YES)
-@Composable
-fun PeerPushResponsePreview() {
- TalerSurface {
- val talerUri = "https://example.org/foo/bar/can/be/very/long/url/so/fit/it/on/screen"
- val response = OutgoingResponse(talerUri, QrCodeManager.makeQrCode(talerUri))
- OutgoingPushResultComposable(response) {}
- }
-}
-
-@Preview(widthDp = 720, uiMode = UI_MODE_NIGHT_YES)
-@Composable
-fun PeerPushResponseLandscapePreview() {
- TalerSurface {
- val talerUri = "https://example.org/foo/bar/can/be/very/long/url/so/fit/it/on/screen"
- val response = OutgoingResponse(talerUri, QrCodeManager.makeQrCode(talerUri))
- OutgoingPushResultComposable(response) {}
- }
-}
-
-@Preview
-@Composable
-fun PeerPushErrorPreview() {
- Surface {
- val json = JSONObject().apply { put("foo", "bar") }
- val response = OutgoingError(TalerErrorInfo(WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json))
- OutgoingPushResultComposable(response) {}
- }
-}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt
index 0e01056..05da294 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt
@@ -16,16 +16,22 @@
package net.taler.wallet.peer
-import android.graphics.Bitmap
import kotlinx.serialization.Serializable
+import net.taler.common.Amount
import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.exchanges.ExchangeItem
sealed class OutgoingState
object OutgoingIntro : OutgoingState()
+object OutgoingChecking : OutgoingState()
+data class OutgoingChecked(
+ val amountRaw: Amount,
+ val amountEffective: Amount,
+ val exchangeItem: ExchangeItem? = null,
+) : OutgoingState()
object OutgoingCreating : OutgoingState()
data class OutgoingResponse(
- val talerUri: String,
- val qrCode: Bitmap,
+ val transactionId: String,
) : OutgoingState()
data class OutgoingError(
@@ -33,15 +39,25 @@ data class OutgoingError(
) : OutgoingState()
@Serializable
+data class CheckPeerPullCreditResponse(
+ val exchangeBaseUrl: String,
+ val amountRaw: Amount,
+ val amountEffective: Amount,
+)
+
+@Serializable
data class InitiatePeerPullPaymentResponse(
- /**
- * Taler URI for the other party to make the payment that was requested.
- */
- val talerUri: String,
+ val transactionId: String,
+)
+
+@Serializable
+data class CheckPeerPushDebitResponse(
+ val amountRaw: Amount,
+ val amountEffective: Amount,
)
@Serializable
-data class InitiatePeerPushPaymentResponse(
+data class InitiatePeerPushDebitResponse(
val exchangeBaseUrl: String,
- val talerUri: String,
+ val transactionId: String,
)
diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt
index 270ca04..5bd2b0b 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt
@@ -25,18 +25,22 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import net.taler.common.Amount
-import net.taler.common.QrCodeManager
import net.taler.common.Timestamp
import net.taler.wallet.TAG
+import net.taler.wallet.backend.TalerErrorCode.UNKNOWN
+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
-import java.util.concurrent.TimeUnit.DAYS
+import java.util.concurrent.TimeUnit.HOURS
const val MAX_LENGTH_SUBJECT = 100
+val DEFAULT_EXPIRY = ExpirationOption.DAYS_1
class PeerManager(
private val api: WalletBackendApi,
+ private val exchangeManager: ExchangeManager,
private val scope: CoroutineScope,
) {
@@ -52,11 +56,37 @@ class PeerManager(
private val _incomingPushState = MutableStateFlow<IncomingState>(IncomingChecking)
val incomingPushState: StateFlow<IncomingState> = _incomingPushState
- fun initiatePullPayment(amount: Amount, summary: String, exchange: ExchangeItem) {
+ fun checkPeerPullCredit(amount: Amount) {
+ _outgoingPullState.value = OutgoingChecking
+ scope.launch(Dispatchers.IO) {
+ val exchangeItem = exchangeManager.findExchange(amount.currency)
+ if (exchangeItem == null) {
+ _outgoingPullState.value = OutgoingError(
+ TalerErrorInfo(UNKNOWN, "No exchange found for ${amount.currency}")
+ )
+ return@launch
+ }
+ api.request("checkPeerPullCredit", CheckPeerPullCreditResponse.serializer()) {
+ put("exchangeBaseUrl", exchangeItem.exchangeBaseUrl)
+ put("amount", amount.toJSONString())
+ }.onSuccess {
+ _outgoingPullState.value = OutgoingChecked(
+ amountRaw = it.amountRaw,
+ amountEffective = it.amountEffective,
+ exchangeItem = exchangeItem,
+ )
+ }.onError { error ->
+ Log.e(TAG, "got checkPeerPullCredit error result $error")
+ _outgoingPullState.value = OutgoingError(error)
+ }
+ }
+ }
+
+ fun initiatePeerPullCredit(amount: Amount, summary: String, expirationHours: Long, exchange: ExchangeItem) {
_outgoingPullState.value = OutgoingCreating
scope.launch(Dispatchers.IO) {
- val expiry = Timestamp.fromMillis(System.currentTimeMillis() + DAYS.toMillis(3))
- api.request("initiatePeerPullPayment", InitiatePeerPullPaymentResponse.serializer()) {
+ val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours))
+ api.request("initiatePeerPullCredit", InitiatePeerPullPaymentResponse.serializer()) {
put("exchangeBaseUrl", exchange.exchangeBaseUrl)
put("partialContractTerms", JSONObject().apply {
put("amount", amount.toJSONString())
@@ -64,10 +94,9 @@ class PeerManager(
put("purse_expiration", JSONObject(Json.encodeToString(expiry)))
})
}.onSuccess {
- val qrCode = QrCodeManager.makeQrCode(it.talerUri)
- _outgoingPullState.value = OutgoingResponse(it.talerUri, qrCode)
+ _outgoingPullState.value = OutgoingResponse(it.transactionId)
}.onError { error ->
- Log.e(TAG, "got initiatePeerPullPayment error result $error")
+ Log.e(TAG, "got initiatePeerPullCredit error result $error")
_outgoingPullState.value = OutgoingError(error)
}
}
@@ -77,11 +106,29 @@ class PeerManager(
_outgoingPullState.value = OutgoingIntro
}
- fun initiatePeerPushPayment(amount: Amount, summary: String) {
+ fun checkPeerPushDebit(amount: Amount) {
+ _outgoingPushState.value = OutgoingChecking
+ scope.launch(Dispatchers.IO) {
+ api.request("checkPeerPushDebit", CheckPeerPushDebitResponse.serializer()) {
+ put("amount", amount.toJSONString())
+ }.onSuccess { response ->
+ _outgoingPushState.value = OutgoingChecked(
+ amountRaw = response.amountRaw,
+ amountEffective = response.amountEffective,
+ // FIXME add exchangeItem once available in API
+ )
+ }.onError { error ->
+ Log.e(TAG, "got checkPeerPushDebit error result $error")
+ _outgoingPushState.value = OutgoingError(error)
+ }
+ }
+ }
+
+ fun initiatePeerPushDebit(amount: Amount, summary: String, expirationHours: Long) {
_outgoingPushState.value = OutgoingCreating
scope.launch(Dispatchers.IO) {
- val expiry = Timestamp.fromMillis(System.currentTimeMillis() + DAYS.toMillis(3))
- api.request("initiatePeerPushPayment", InitiatePeerPushPaymentResponse.serializer()) {
+ val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours))
+ api.request("initiatePeerPushDebit", InitiatePeerPushDebitResponse.serializer()) {
put("amount", amount.toJSONString())
put("partialContractTerms", JSONObject().apply {
put("amount", amount.toJSONString())
@@ -89,10 +136,9 @@ class PeerManager(
put("purse_expiration", JSONObject(Json.encodeToString(expiry)))
})
}.onSuccess { response ->
- val qrCode = QrCodeManager.makeQrCode(response.talerUri)
- _outgoingPushState.value = OutgoingResponse(response.talerUri, qrCode)
+ _outgoingPushState.value = OutgoingResponse(response.transactionId)
}.onError { error ->
- Log.e(TAG, "got initiatePeerPushPayment error result $error")
+ Log.e(TAG, "got initiatePeerPushDebit error result $error")
_outgoingPushState.value = OutgoingError(error)
}
}
@@ -102,65 +148,67 @@ class PeerManager(
_outgoingPushState.value = OutgoingIntro
}
- fun checkPeerPullPayment(talerUri: String) {
+ fun preparePeerPullDebit(talerUri: String) {
_incomingPullState.value = IncomingChecking
scope.launch(Dispatchers.IO) {
- api.request("checkPeerPullPayment", CheckPeerPullPaymentResponse.serializer()) {
+ api.request("preparePeerPullDebit", PreparePeerPullDebitResponse.serializer()) {
put("talerUri", talerUri)
}.onSuccess { response ->
_incomingPullState.value = IncomingTerms(
- amount = response.amount,
+ amountRaw = response.amountRaw,
+ amountEffective = response.amountEffective,
contractTerms = response.contractTerms,
- id = response.peerPullPaymentIncomingId,
+ id = response.transactionId,
)
}.onError { error ->
- Log.e(TAG, "got checkPeerPushPayment error result $error")
+ Log.e(TAG, "got preparePeerPullDebit error result $error")
_incomingPullState.value = IncomingError(error)
}
}
}
- fun acceptPeerPullPayment(terms: IncomingTerms) {
+ fun confirmPeerPullDebit(terms: IncomingTerms) {
_incomingPullState.value = IncomingAccepting(terms)
scope.launch(Dispatchers.IO) {
- api.request<Unit>("acceptPeerPullPayment") {
- put("peerPullPaymentIncomingId", terms.id)
+ api.request<Unit>("confirmPeerPullDebit") {
+ put("transactionId", terms.id)
}.onSuccess {
_incomingPullState.value = IncomingAccepted
}.onError { error ->
- Log.e(TAG, "got checkPeerPushPayment error result $error")
+ Log.e(TAG, "got confirmPeerPullDebit error result $error")
_incomingPullState.value = IncomingError(error)
}
}
}
- fun checkPeerPushPayment(talerUri: String) {
+ fun preparePeerPushCredit(talerUri: String) {
_incomingPushState.value = IncomingChecking
scope.launch(Dispatchers.IO) {
- api.request("checkPeerPushPayment", CheckPeerPushPaymentResponse.serializer()) {
+ api.request("preparePeerPushCredit", PreparePeerPushCreditResponse.serializer()) {
put("talerUri", talerUri)
}.onSuccess { response ->
_incomingPushState.value = IncomingTerms(
- amount = response.amount,
+ amountRaw = response.amountRaw,
+ amountEffective = response.amountEffective,
contractTerms = response.contractTerms,
- id = response.peerPushPaymentIncomingId,
+ id = response.transactionId,
)
}.onError { error ->
- Log.e(TAG, "got checkPeerPushPayment error result $error")
+ Log.e(TAG, "got preparePeerPushCredit error result $error")
_incomingPushState.value = IncomingError(error)
}
}
}
- fun acceptPeerPushPayment(terms: IncomingTerms) {
+ fun confirmPeerPushCredit(terms: IncomingTerms) {
_incomingPushState.value = IncomingAccepting(terms)
scope.launch(Dispatchers.IO) {
- api.request<Unit>("acceptPeerPushPayment") {
- put("peerPushPaymentIncomingId", terms.id)
+ api.request<Unit>("confirmPeerPushCredit") {
+ put("transactionId", terms.id)
}.onSuccess {
_incomingPushState.value = IncomingAccepted
}.onError { error ->
- Log.e(TAG, "got checkPeerPushPayment error result $error")
+ Log.e(TAG, "got confirmPeerPushCredit error result $error")
_incomingPushState.value = IncomingError(error)
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt
index 92264c7..59d405c 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt
@@ -17,82 +17,89 @@
package net.taler.wallet.peer
import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import net.taler.common.Amount
+import net.taler.common.CurrencySpecification
import net.taler.common.Timestamp
import net.taler.wallet.R
-import net.taler.wallet.compose.QrCodeUriComposable
+import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED
+import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.transactions.AmountType
import net.taler.wallet.transactions.PeerInfoShort
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
import net.taler.wallet.transactions.TransactionAmountComposable
import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import net.taler.wallet.transactions.TransactionMinorState.CreatePurse
+import net.taler.wallet.transactions.TransactionMinorState.Ready
import net.taler.wallet.transactions.TransactionPeerComposable
import net.taler.wallet.transactions.TransactionPeerPullCredit
+import net.taler.wallet.transactions.TransactionState
@Composable
-fun ColumnScope.TransactionPeerPullCreditComposable(t: TransactionPeerPullCredit) {
- TransactionAmountComposable(
- label = stringResource(id = R.string.receive_amount),
- amount = t.amountEffective,
- amountType = AmountType.Positive,
+fun ColumnScope.TransactionPeerPullCreditComposable(t: TransactionPeerPullCredit, spec: CurrencySpecification?) {
+ if (t.error == null) PeerQrCode(
+ state = t.txState,
+ talerUri = t.talerUri,
)
+
TransactionAmountComposable(
- label = stringResource(id = R.string.amount_chosen),
- amount = t.amountRaw,
+ label = stringResource(id = R.string.amount_invoiced),
+ amount = t.amountRaw.withSpec(spec),
amountType = AmountType.Neutral,
)
- val fee = t.amountRaw - t.amountEffective
- if (!fee.isZero()) {
+
+ if (t.amountRaw > t.amountEffective) {
+ val fee = t.amountRaw - t.amountEffective
TransactionAmountComposable(
- label = stringResource(id = R.string.withdraw_fees),
- amount = fee,
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(spec),
amountType = AmountType.Negative,
)
}
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_received),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Positive,
+ )
+
TransactionInfoComposable(
label = stringResource(id = R.string.send_peer_purpose),
info = t.info.summary ?: "",
)
- if (t.pending) {
- QrCodeUriComposable(
- talerUri = t.talerUri,
- clipBoardLabel = "Invoice",
- buttonText = stringResource(id = R.string.copy),
- ) {
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- style = MaterialTheme.typography.body1,
- text = stringResource(id = R.string.receive_peer_invoice_uri),
- )
- }
- }
}
@Preview
@Composable
-fun TransactionPeerPullCreditPreview() {
+fun TransactionPeerPullCreditPreview(loading: Boolean = false) {
val t = TransactionPeerPullCredit(
transactionId = "transactionId",
timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
- pending = true,
+ txState = TransactionState(Pending, if (loading) CreatePurse else Ready),
+ txActions = listOf(Retry, Suspend, Abort),
exchangeBaseUrl = "https://exchange.example.org/",
- amountRaw = Amount.fromDouble("TESTKUDOS", 42.23),
- amountEffective = Amount.fromDouble("TESTKUDOS", 42.1337),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.1337"),
info = PeerInfoShort(
expiration = Timestamp.fromMillis(System.currentTimeMillis() + 60 * 60 * 1000),
summary = "test invoice",
),
talerUri = "https://exchange.example.org/peer/pull/credit",
+ error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED),
)
Surface {
- TransactionPeerComposable(t) {}
+ TransactionPeerComposable(t, true, null) {}
}
}
+
+@Preview
+@Composable
+fun TransactionPeerPullCreditLoadingPreview() {
+ TransactionPeerPullCreditPreview(loading = true)
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt
index d09a659..b8966d4 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt
@@ -16,40 +16,51 @@
package net.taler.wallet.peer
-import androidx.compose.material.Surface
+import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import net.taler.common.Amount
+import net.taler.common.CurrencySpecification
import net.taler.common.Timestamp
import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED
+import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.transactions.AmountType
import net.taler.wallet.transactions.PeerInfoShort
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
import net.taler.wallet.transactions.TransactionAmountComposable
import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionMajorState.Pending
import net.taler.wallet.transactions.TransactionPeerComposable
import net.taler.wallet.transactions.TransactionPeerPullDebit
+import net.taler.wallet.transactions.TransactionState
@Composable
-fun TransactionPeerPullDebitComposable(t: TransactionPeerPullDebit) {
- TransactionAmountComposable(
- label = stringResource(id = R.string.transaction_paid),
- amount = t.amountEffective,
- amountType = AmountType.Negative,
- )
+fun TransactionPeerPullDebitComposable(t: TransactionPeerPullDebit, spec: CurrencySpecification?) {
TransactionAmountComposable(
label = stringResource(id = R.string.transaction_order_total),
- amount = t.amountRaw,
+ amount = t.amountRaw.withSpec(spec),
amountType = AmountType.Neutral,
)
- val fee = t.amountEffective - t.amountRaw
- if (!fee.isZero()) {
+
+ if (t.amountEffective > t.amountRaw) {
+ val fee = t.amountEffective - t.amountRaw
TransactionAmountComposable(
- label = stringResource(id = R.string.withdraw_fees),
- amount = fee,
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(spec),
amountType = AmountType.Negative,
)
}
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.transaction_paid),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+
TransactionInfoComposable(
label = stringResource(id = R.string.send_peer_purpose),
info = t.info.summary ?: "",
@@ -62,16 +73,18 @@ fun TransactionPeerPullDebitPreview() {
val t = TransactionPeerPullDebit(
transactionId = "transactionId",
timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
- pending = true,
+ txState = TransactionState(Pending),
+ txActions = listOf(Retry, Suspend, Abort),
exchangeBaseUrl = "https://exchange.example.org/",
- amountRaw = Amount.fromDouble("TESTKUDOS", 42.1337),
- amountEffective = Amount.fromDouble("TESTKUDOS", 42.23),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.1337"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.23"),
info = PeerInfoShort(
expiration = Timestamp.fromMillis(System.currentTimeMillis() + 60 * 60 * 1000),
summary = "test invoice",
),
+ error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED),
)
Surface {
- TransactionPeerComposable(t) {}
+ TransactionPeerComposable(t, true, null) {}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt
index e918c42..d407ff2 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt
@@ -16,40 +16,51 @@
package net.taler.wallet.peer
-import androidx.compose.material.Surface
+import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import net.taler.common.Amount
+import net.taler.common.CurrencySpecification
import net.taler.common.Timestamp
import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED
+import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.transactions.AmountType
import net.taler.wallet.transactions.PeerInfoShort
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
import net.taler.wallet.transactions.TransactionAmountComposable
import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionMajorState.Pending
import net.taler.wallet.transactions.TransactionPeerComposable
import net.taler.wallet.transactions.TransactionPeerPushCredit
+import net.taler.wallet.transactions.TransactionState
@Composable
-fun TransactionPeerPushCreditComposable(t: TransactionPeerPushCredit) {
+fun TransactionPeerPushCreditComposable(t: TransactionPeerPushCredit, spec: CurrencySpecification?) {
TransactionAmountComposable(
- label = stringResource(id = R.string.send_peer_payment_amount_received),
- amount = t.amountEffective,
- amountType = AmountType.Positive,
- )
- TransactionAmountComposable(
- label = stringResource(id = R.string.send_peer_payment_amount_sent),
- amount = t.amountRaw,
+ label = stringResource(id = R.string.amount_sent),
+ amount = t.amountRaw.withSpec(spec),
amountType = AmountType.Neutral,
)
- val fee = t.amountRaw - t.amountEffective
- if (!fee.isZero()) {
+
+ if (t.amountRaw > t.amountEffective) {
+ val fee = t.amountRaw - t.amountEffective
TransactionAmountComposable(
- label = stringResource(id = R.string.withdraw_fees),
- amount = fee,
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(spec),
amountType = AmountType.Negative,
)
}
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_received),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Positive,
+ )
+
TransactionInfoComposable(
label = stringResource(id = R.string.send_peer_purpose),
info = t.info.summary ?: "",
@@ -62,16 +73,18 @@ fun TransactionPeerPushCreditPreview() {
val t = TransactionPeerPushCredit(
transactionId = "transactionId",
timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
- pending = true,
+ txState = TransactionState(Pending),
+ txActions = listOf(Retry, Suspend, Abort),
exchangeBaseUrl = "https://exchange.example.org/",
- amountRaw = Amount.fromDouble("TESTKUDOS", 42.23),
- amountEffective = Amount.fromDouble("TESTKUDOS", 42.1337),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.1337"),
info = PeerInfoShort(
expiration = Timestamp.fromMillis(System.currentTimeMillis() + 60 * 60 * 1000),
summary = "test invoice",
),
+ error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED),
)
Surface {
- TransactionPeerComposable(t) {}
+ TransactionPeerComposable(t, true, null) {}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt
index 88c27fe..f2edc19 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt
@@ -18,79 +18,135 @@ package net.taler.wallet.peer
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.taler.common.Amount
+import net.taler.common.CurrencySpecification
import net.taler.common.Timestamp
import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED
+import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.compose.QrCodeUriComposable
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.compose.getQrCodeSize
import net.taler.wallet.transactions.AmountType
import net.taler.wallet.transactions.PeerInfoShort
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
import net.taler.wallet.transactions.TransactionAmountComposable
import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import net.taler.wallet.transactions.TransactionMinorState.CreatePurse
+import net.taler.wallet.transactions.TransactionMinorState.Ready
import net.taler.wallet.transactions.TransactionPeerComposable
import net.taler.wallet.transactions.TransactionPeerPushDebit
+import net.taler.wallet.transactions.TransactionState
@Composable
-fun ColumnScope.TransactionPeerPushDebitComposable(t: TransactionPeerPushDebit) {
- TransactionAmountComposable(
- label = stringResource(id = R.string.transaction_paid),
- amount = t.amountEffective,
- amountType = AmountType.Negative,
+fun ColumnScope.TransactionPeerPushDebitComposable(t: TransactionPeerPushDebit, spec: CurrencySpecification?) {
+ if (t.error == null) PeerQrCode(
+ state = t.txState,
+ talerUri = t.talerUri,
)
+
TransactionAmountComposable(
label = stringResource(id = R.string.transaction_order_total),
- amount = t.amountRaw,
+ amount = t.amountRaw.withSpec(spec),
amountType = AmountType.Neutral,
)
- val fee = t.amountEffective - t.amountRaw
- if (!fee.isZero()) {
+
+ if (t.amountEffective > t.amountRaw) {
+ val fee = t.amountEffective - t.amountRaw
TransactionAmountComposable(
- label = stringResource(id = R.string.withdraw_fees),
- amount = fee,
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(spec),
amountType = AmountType.Negative,
)
}
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.transaction_paid),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+
TransactionInfoComposable(
label = stringResource(id = R.string.send_peer_purpose),
info = t.info.summary ?: "",
)
- QrCodeUriComposable(
- talerUri = t.talerUri,
- clipBoardLabel = "Push payment",
- buttonText = stringResource(id = R.string.copy),
- ) {
+}
+
+@Composable
+fun ColumnScope.PeerQrCode(state: TransactionState, talerUri: String?) {
+ if (state == TransactionState(Pending)) {
Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- style = MaterialTheme.typography.body1,
- text = stringResource(id = R.string.receive_peer_invoice_uri),
+ modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
+ style = MaterialTheme.typography.titleLarge,
+ text = stringResource(id = R.string.send_peer_payment_instruction),
+ textAlign = TextAlign.Center,
)
+
+ if (state.minor == Ready && talerUri != null) {
+ QrCodeUriComposable(
+ talerUri = talerUri,
+ clipBoardLabel = "Push payment",
+ buttonText = stringResource(id = R.string.copy),
+ ) {
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ text = stringResource(id = R.string.receive_peer_invoice_uri),
+ )
+ }
+ } else {
+ val qrCodeSize = getQrCodeSize()
+ CircularProgressIndicator(
+ modifier = Modifier
+ .padding(32.dp)
+ .size(qrCodeSize)
+ .align(CenterHorizontally),
+ )
+ }
}
+
}
@Preview
@Composable
-fun TransactionPeerPushDebitPreview() {
+fun TransactionPeerPushDebitPreview(loading: Boolean = false) {
val t = TransactionPeerPushDebit(
transactionId = "transactionId",
timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
- pending = true,
+ txState = TransactionState(Pending, if (loading) CreatePurse else Ready),
+ txActions = listOf(Retry, Suspend, Abort),
exchangeBaseUrl = "https://exchange.example.org/",
- amountRaw = Amount.fromDouble("TESTKUDOS", 42.1337),
- amountEffective = Amount.fromDouble("TESTKUDOS", 42.23),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.1337"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.23"),
info = PeerInfoShort(
expiration = Timestamp.fromMillis(System.currentTimeMillis() + 60 * 60 * 1000),
summary = "test invoice",
),
talerUri = "https://exchange.example.org/peer/pull/credit",
+ error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED),
)
- Surface {
- TransactionPeerComposable(t) {}
+
+ TalerSurface {
+ TransactionPeerComposable(t, true, null) {}
}
}
+
+@Preview
+@Composable
+fun TransactionPeerPushDebitLoadingPreview() {
+ TransactionPeerPushDebitPreview(loading = true)
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/pending/PendingOperationsFragment.kt b/wallet/src/main/java/net/taler/wallet/pending/PendingOperationsFragment.kt
deleted file mode 100644
index 6bfcf90..0000000
--- a/wallet/src/main/java/net/taler/wallet/pending/PendingOperationsFragment.kt
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
- *
- * GNU Taler is free software; you can redistribute it and/or modify it under the
- * terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3, or (at your option) any later version.
- *
- * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-package net.taler.wallet.pending
-
-import android.os.Bundle
-import android.util.Log
-import android.view.LayoutInflater
-import android.view.Menu
-import android.view.MenuInflater
-import android.view.MenuItem
-import android.view.View
-import android.view.View.GONE
-import android.view.View.VISIBLE
-import android.view.ViewGroup
-import android.widget.LinearLayout
-import android.widget.TextView
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
-import androidx.recyclerview.widget.DividerItemDecoration
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import net.taler.common.showError
-import net.taler.wallet.MainViewModel
-import net.taler.wallet.R
-import net.taler.wallet.TAG
-import net.taler.wallet.databinding.FragmentPendingOperationsBinding
-import org.json.JSONObject
-
-interface PendingOperationClickListener {
- fun onPendingOperationClick(type: String, detail: JSONObject)
- fun onPendingOperationActionClick(type: String, detail: JSONObject)
-}
-
-class PendingOperationsFragment : Fragment(), PendingOperationClickListener {
-
- private val model: MainViewModel by activityViewModels()
- private val pendingOperationsManager by lazy { model.pendingOperationsManager }
-
- private lateinit var ui: FragmentPendingOperationsBinding
- private val pendingAdapter = PendingOperationsAdapter(emptyList(), this)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- ui = FragmentPendingOperationsBinding.inflate(inflater, container, false)
- return ui.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- ui.listPending.apply {
- val myLayoutManager = LinearLayoutManager(requireContext())
- val myItemDecoration =
- DividerItemDecoration(requireContext(), myLayoutManager.orientation)
- layoutManager = myLayoutManager
- adapter = pendingAdapter
- addItemDecoration(myItemDecoration)
- }
-
- pendingOperationsManager.pendingOperations.observe(viewLifecycleOwner) {
- updatePending(it)
- }
- }
-
- override fun onStart() {
- super.onStart()
- pendingOperationsManager.getPending()
- }
-
- @Deprecated("Deprecated in Java")
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.retry_pending -> {
- pendingOperationsManager.retryPendingNow()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- @Deprecated("Deprecated in Java")
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.pending_operations, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- private fun updatePending(pendingOperations: List<PendingOperationInfo>) {
- pendingAdapter.update(pendingOperations)
- }
-
- override fun onPendingOperationClick(type: String, detail: JSONObject) {
- requireActivity().showError("No detail view for $type implemented yet.")
- }
-
- override fun onPendingOperationActionClick(type: String, detail: JSONObject) {
- when (type) {
- "proposal-choice" -> {
- Log.v(TAG, "got action click on proposal-choice")
- val proposalId = detail.optString("proposalId", "")
- if (proposalId == "") {
- return
- }
- model.paymentManager.abortProposal(proposalId)
- }
- }
- }
-
-}
-
-class PendingOperationsAdapter(
- private var items: List<PendingOperationInfo>,
- private val listener: PendingOperationClickListener
-) :
- RecyclerView.Adapter<PendingOperationsAdapter.MyViewHolder>() {
-
- init {
- setHasStableIds(false)
- }
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
- val rowView =
- LayoutInflater.from(parent.context).inflate(R.layout.list_item_pending_operation, parent, false)
- return MyViewHolder(rowView)
- }
-
- override fun getItemCount(): Int {
- return items.size
- }
-
- override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
- val p = items[position]
- val pendingContainer = holder.rowView.findViewById<LinearLayout>(R.id.pending_container)
- pendingContainer.setOnClickListener {
- listener.onPendingOperationClick(p.type, p.detail)
- }
- when (p.type) {
- "proposal-choice" -> {
- val btn1 = holder.rowView.findViewById<TextView>(R.id.button_pending_action_1)
- btn1.text = btn1.context.getString(R.string.pending_operations_refuse)
- btn1.visibility = VISIBLE
- btn1.setOnClickListener {
- listener.onPendingOperationActionClick(p.type, p.detail)
- }
- }
- else -> {
- val btn1 = holder.rowView.findViewById<TextView>(R.id.button_pending_action_1)
- btn1.text = btn1.context.getString(R.string.pending_operations_no_action)
- btn1.visibility = GONE
- btn1.setOnClickListener {}
- }
- }
- val textView = holder.rowView.findViewById<TextView>(R.id.pending_text)
- val subTextView = holder.rowView.findViewById<TextView>(R.id.pending_subtext)
- subTextView.text = p.detail.toString(1)
- textView.text = p.type
- }
-
- fun update(items: List<PendingOperationInfo>) {
- this.items = items
- this.notifyDataSetChanged()
- }
-
- class MyViewHolder(val rowView: View) : RecyclerView.ViewHolder(rowView)
-
-}
diff --git a/wallet/src/main/java/net/taler/wallet/pending/PendingOperationsManager.kt b/wallet/src/main/java/net/taler/wallet/pending/PendingOperationsManager.kt
deleted file mode 100644
index df778ed..0000000
--- a/wallet/src/main/java/net/taler/wallet/pending/PendingOperationsManager.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
- *
- * GNU Taler is free software; you can redistribute it and/or modify it under the
- * terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3, or (at your option) any later version.
- *
- * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-package net.taler.wallet.pending
-
-import android.util.Log
-import androidx.lifecycle.MutableLiveData
-import net.taler.wallet.TAG
-import net.taler.wallet.backend.WalletBackendApi
-import org.json.JSONObject
-
-open class PendingOperationInfo(
- val type: String,
- val detail: JSONObject
-)
-
-class PendingOperationsManager(private val walletBackendApi: WalletBackendApi) {
-
- val pendingOperations = MutableLiveData<List<PendingOperationInfo>>()
-
- internal fun getPending() {
- walletBackendApi.sendRequest("getPendingOperations") { isError, result ->
- if (isError) {
- Log.i(TAG, "got getPending error result: ${result.toString(2)}")
- return@sendRequest
- }
- Log.i(TAG, "got getPending result")
- val pendingList = mutableListOf<PendingOperationInfo>()
- val pendingJson = result.getJSONArray("pendingOperations")
- for (i in 0 until pendingJson.length()) {
- val p = pendingJson.getJSONObject(i)
- val type = p.getString("type")
- pendingList.add(PendingOperationInfo(type, p))
- }
- Log.i(TAG, "Got ${pendingList.size} pending operations")
- pendingOperations.postValue((pendingList))
- }
- }
-
- fun retryPendingNow() {
- walletBackendApi.sendRequest("retryPendingNow")
- }
-
-}
diff --git a/wallet/src/main/java/net/taler/wallet/refund/RefundManager.kt b/wallet/src/main/java/net/taler/wallet/refund/RefundManager.kt
index f3c41e8..96e939b 100644
--- a/wallet/src/main/java/net/taler/wallet/refund/RefundManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/refund/RefundManager.kt
@@ -21,20 +21,17 @@ import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
-import net.taler.common.Amount
+import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.backend.WalletBackendApi
sealed class RefundStatus {
- data class Error(val msg: String) : RefundStatus()
- data class Success(val response: RefundResponse) : RefundStatus()
+ data class Error(val error: TalerErrorInfo) : RefundStatus()
+ data class Success(val response: StartRefundQueryForUriResponse) : RefundStatus()
}
@Serializable
-data class RefundResponse(
- val amountEffectivePaid: Amount,
- val amountRefundGranted: Amount,
- val amountRefundGone: Amount,
- val pendingAtExchange: Boolean
+data class StartRefundQueryForUriResponse(
+ val transactionId: String,
)
class RefundManager(
@@ -45,10 +42,10 @@ class RefundManager(
fun refund(refundUri: String): LiveData<RefundStatus> {
val liveData = MutableLiveData<RefundStatus>()
scope.launch {
- api.request("applyRefund", RefundResponse.serializer()) {
+ api.request("startRefundQueryForUri", StartRefundQueryForUriResponse.serializer()) {
put("talerRefundUri", refundUri)
}.onError {
- liveData.postValue(RefundStatus.Error(it.userFacingMsg))
+ liveData.postValue(RefundStatus.Error(it))
}.onSuccess {
liveData.postValue(RefundStatus.Success(it))
}
diff --git a/wallet/src/main/java/net/taler/wallet/refund/RefundPaymentInfo.kt b/wallet/src/main/java/net/taler/wallet/refund/RefundPaymentInfo.kt
new file mode 100644
index 0000000..d5f59be
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/refund/RefundPaymentInfo.kt
@@ -0,0 +1,39 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.refund
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+class RefundPaymentInfo(
+ val summary: String,
+ @SerialName("summary_i18n")
+ val summaryI18n: Map<String, String>? = null,
+ /**
+ * More information about the merchant
+ */
+ val merchant: MerchantInfo,
+)
+
+@Serializable
+class MerchantInfo(
+ val name: String,
+ val logo: String? = null,
+ val website: String? = null,
+ val email: String? = null,
+)
diff --git a/wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt b/wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt
new file mode 100644
index 0000000..b17658a
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt
@@ -0,0 +1,123 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.refund
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.common.CurrencySpecification
+import net.taler.common.Timestamp
+import net.taler.common.toAbsoluteTime
+import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.transactions.AmountType
+import net.taler.wallet.transactions.ErrorTransactionButton
+import net.taler.wallet.transactions.TransactionAction
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
+import net.taler.wallet.transactions.TransactionAmountComposable
+import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import net.taler.wallet.transactions.TransactionRefund
+import net.taler.wallet.transactions.TransactionState
+import net.taler.wallet.transactions.TransitionsComposable
+
+@Composable
+fun TransactionRefundComposable(
+ t: TransactionRefund,
+ devMode: Boolean,
+ spec: CurrencySpecification?,
+ onTransition: (t: TransactionAction) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ val context = LocalContext.current
+ Text(
+ modifier = Modifier.padding(16.dp),
+ text = t.timestamp.ms.toAbsoluteTime(context).toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.transaction_refund),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Positive,
+ )
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.transaction_order_total),
+ amount = t.amountRaw.withSpec(spec),
+ amountType = AmountType.Neutral,
+ )
+ if (t.amountRaw > t.amountEffective) {
+ val fee = t.amountRaw - t.amountEffective
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+ }
+ TransactionInfoComposable(
+ label = stringResource(id = R.string.transaction_order),
+ info = t.paymentInfo?.summary ?: "",
+ )
+ TransitionsComposable(t, devMode, onTransition)
+ if (devMode && t.error != null) {
+ ErrorTransactionButton(error = t.error)
+ }
+ }
+}
+
+@Preview
+@Composable
+fun TransactionRefundComposablePreview() {
+ val t = TransactionRefund(
+ transactionId = "transactionId",
+ timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
+ txState = TransactionState(Pending),
+ txActions = listOf(Retry, Suspend, Abort),
+ paymentInfo = RefundPaymentInfo(
+ merchant = MerchantInfo(name = "Taler"),
+ summary = "Some Product that was bought and can have quite a long label",
+ ),
+ refundedTransactionId = "transactionId",
+ amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.1337"),
+ error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED),
+ )
+ TalerSurface {
+ TransactionRefundComposable(t = t, devMode = true, spec = null) {}
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt
index 3d31dc5..38eeb9b 100644
--- a/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt
@@ -18,22 +18,22 @@ package net.taler.wallet.settings
import android.os.Bundle
import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
-import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT
import com.google.android.material.snackbar.Snackbar
import net.taler.common.showError
-import net.taler.common.toRelativeTime
import net.taler.wallet.BuildConfig.FLAVOR
import net.taler.wallet.BuildConfig.VERSION_CODE
import net.taler.wallet.BuildConfig.VERSION_NAME
-import net.taler.wallet.BuildConfig.WALLET_CORE_VERSION
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
+import net.taler.wallet.showError
import net.taler.wallet.withdraw.WithdrawTestStatus
import java.lang.System.currentTimeMillis
@@ -44,71 +44,74 @@ class SettingsFragment : PreferenceFragmentCompat() {
private val settingsManager get() = model.settingsManager
private val withdrawManager by lazy { model.withdrawManager }
- private lateinit var prefBackup: Preference
private lateinit var prefDevMode: SwitchPreferenceCompat
private lateinit var prefWithdrawTest: Preference
private lateinit var prefLogcat: Preference
private lateinit var prefExportDb: Preference
+ private lateinit var prefImportDb: Preference
private lateinit var prefVersionApp: Preference
private lateinit var prefVersionCore: Preference
private lateinit var prefVersionExchange: Preference
private lateinit var prefVersionMerchant: Preference
+ private lateinit var prefTest: Preference
private lateinit var prefReset: Preference
private val devPrefs by lazy {
listOf(
- prefBackup,
+ prefVersionCore,
prefWithdrawTest,
prefLogcat,
prefExportDb,
- prefVersionApp,
- prefVersionCore,
+ prefImportDb,
prefVersionExchange,
prefVersionMerchant,
- prefReset
+ prefTest,
+ prefReset,
)
}
private val logLauncher = registerForActivityResult(CreateDocument("text/plain")) { uri ->
settingsManager.exportLogcat(uri)
}
- private val dbExportLauncher = registerForActivityResult(CreateDocument("application/json")) { uri ->
- settingsManager.exportDb(uri)
- }
+ private val dbExportLauncher =
+ registerForActivityResult(CreateDocument("application/json")) { uri ->
+ settingsManager.exportDb(uri)
+ }
+ private val dbImportLauncher =
+ registerForActivityResult(OpenDocument()) { uri ->
+ settingsManager.importDb(uri)
+ }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_main, rootKey)
- prefBackup = findPreference("pref_backup")!!
prefDevMode = findPreference("pref_dev_mode")!!
prefWithdrawTest = findPreference("pref_testkudos")!!
prefLogcat = findPreference("pref_logcat")!!
prefExportDb = findPreference("pref_export_db")!!
+ prefImportDb = findPreference("pref_import_db")!!
prefVersionApp = findPreference("pref_version_app")!!
prefVersionCore = findPreference("pref_version_core")!!
prefVersionExchange = findPreference("pref_version_protocol_exchange")!!
prefVersionMerchant = findPreference("pref_version_protocol_merchant")!!
+ prefTest = findPreference("pref_test")!!
prefReset = findPreference("pref_reset")!!
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- model.lastBackup.observe(viewLifecycleOwner) {
- val time = it.toRelativeTime(requireContext())
- prefBackup.summary = getString(R.string.backup_last, time)
- }
+ prefVersionApp.summary = "$VERSION_NAME ($FLAVOR $VERSION_CODE)"
+ prefVersionCore.summary = "${model.walletVersion} (${model.walletVersionHash?.take(7)})"
+ model.exchangeVersion?.let { prefVersionExchange.summary = it }
+ model.merchantVersion?.let { prefVersionMerchant.summary = it }
model.devMode.observe(viewLifecycleOwner) { enabled ->
prefDevMode.isChecked = enabled
- if (enabled) {
- prefVersionApp.summary = "$VERSION_NAME ($FLAVOR $VERSION_CODE)"
- prefVersionCore.summary = WALLET_CORE_VERSION
- model.exchangeVersion?.let { prefVersionExchange.summary = it }
- model.merchantVersion?.let { prefVersionMerchant.summary = it }
- }
devPrefs.forEach { it.isVisible = enabled }
}
prefDevMode.setOnPreferenceChangeListener { _, newValue ->
- model.devMode.value = newValue as Boolean
+ model.setDevMode(newValue as Boolean) { error ->
+ showError(error)
+ }
true
}
@@ -135,22 +138,43 @@ class SettingsFragment : PreferenceFragmentCompat() {
dbExportLauncher.launch("taler-wallet-db-${currentTimeMillis()}.json")
true
}
-
+ prefImportDb.setOnPreferenceClickListener {
+ showImportDialog()
+ true
+ }
+ prefTest.setOnPreferenceClickListener {
+ model.runIntegrationTest()
+ true
+ }
prefReset.setOnPreferenceClickListener {
showResetDialog()
true
}
}
+ private fun showImportDialog() {
+ MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3)
+ .setMessage(R.string.settings_dialog_import_message)
+ .setNegativeButton(R.string.import_db) { _, _ ->
+ dbImportLauncher.launch(arrayOf("application/json"))
+ }
+ .setPositiveButton(R.string.cancel) { _, _ ->
+ Snackbar.make(requireView(), getString(R.string.settings_alert_import_canceled), LENGTH_SHORT).show()
+ }
+ .show()
+ }
+
private fun showResetDialog() {
- AlertDialog.Builder(requireContext(), R.style.DialogTheme)
- .setMessage("Do you really want to reset the wallet and lose all coins and purchases?")
- .setPositiveButton("Reset") { _, _ ->
- model.dangerouslyReset()
- Snackbar.make(requireView(), "Wallet has been reset", LENGTH_SHORT).show()
+ MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3)
+ .setMessage(R.string.settings_dialog_reset_message)
+ .setNegativeButton(R.string.reset) { _, _ ->
+ settingsManager.clearDb {
+ model.dangerouslyReset()
+ }
+ Snackbar.make(requireView(), getString(R.string.settings_alert_reset_done), LENGTH_SHORT).show()
}
- .setNegativeButton("Cancel") { _, _ ->
- Snackbar.make(requireView(), "Reset cancelled", LENGTH_SHORT).show()
+ .setPositiveButton(R.string.cancel) { _, _ ->
+ Snackbar.make(requireView(), getString(R.string.settings_alert_reset_canceled), LENGTH_SHORT).show()
}
.show()
}
diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt
index 349c7b1..8331d59 100644
--- a/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt
@@ -25,14 +25,19 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
import net.taler.wallet.R
-import net.taler.wallet.backend.WALLET_DB
+import net.taler.wallet.backend.WalletBackendApi
+import net.taler.wallet.backend.WalletResponse.Error
+import net.taler.wallet.backend.WalletResponse.Success
+import org.json.JSONObject
class SettingsManager(
private val context: Context,
+ private val api: WalletBackendApi,
private val scope: CoroutineScope,
) {
-
fun exportLogcat(uri: Uri?) {
if (uri == null) {
onLogExportError()
@@ -65,20 +70,88 @@ class SettingsManager(
onDbExportError()
return
}
+
scope.launch(Dispatchers.IO) {
- try {
- context.contentResolver.openOutputStream(uri, "wt")?.use { outputStream ->
- context.openFileInput(WALLET_DB).use { inputStream ->
- inputStream.copyTo(outputStream)
+ when (val response = api.rawRequest("exportDb")) {
+ is Success -> {
+ try {
+ context.contentResolver.openOutputStream(uri, "wt")?.use { outputStream ->
+ val data = Json.encodeToString(response.result)
+ val writer = outputStream.bufferedWriter()
+ writer.write(data)
+ writer.close()
+ }
+ } catch(e: Exception) {
+ Log.e(SettingsManager::class.simpleName, "Error exporting db: ", e)
+ withContext(Dispatchers.Main) {
+ onDbExportError()
+ }
+ return@launch
}
- } ?: onDbExportError()
- } catch (e: Exception) {
- Log.e(SettingsManager::class.simpleName, "Error exporting db: ", e)
- onDbExportError()
- return@launch
+
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, R.string.settings_db_export_success, LENGTH_LONG).show()
+ }
+ }
+ is Error -> {
+ Log.e(SettingsManager::class.simpleName, "Error exporting db: ${response.error}")
+ withContext(Dispatchers.Main) {
+ onDbExportError()
+ }
+ return@launch
+ }
}
- withContext(Dispatchers.Main) {
- Toast.makeText(context, R.string.settings_db_export_success, LENGTH_LONG).show()
+ }
+ }
+
+ fun importDb(uri: Uri?) {
+ if (uri == null) {
+ onDbImportError()
+ return
+ }
+
+ scope.launch(Dispatchers.IO) {
+ context.contentResolver.openInputStream(uri)?.use { inputStream ->
+ try {
+ val reader = inputStream.bufferedReader()
+ val strData = reader.readText()
+ reader.close()
+ val jsonData = JSONObject(strData)
+ when (val response = api.rawRequest("importDb") {
+ put("dump", jsonData)
+ }) {
+ is Success -> {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, R.string.settings_db_import_success, LENGTH_LONG).show()
+ }
+ }
+ is Error -> {
+ Log.e(SettingsManager::class.simpleName, "Error importing db: ${response.error}")
+ withContext(Dispatchers.Main) {
+ onDbImportError()
+ }
+ return@launch
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(SettingsManager::class.simpleName, "Error importing db: ", e)
+ withContext(Dispatchers.Main) {
+ onDbImportError()
+ }
+ return@launch
+ }
+ }
+ }
+ }
+
+ fun clearDb(onSuccess: () -> Unit) {
+ scope.launch {
+ when (val response = api.rawRequest("clearDb")) {
+ is Success -> onSuccess()
+ is Error -> {
+ Log.e(SettingsManager::class.simpleName, "Error cleaning db: ${response.error}")
+ onDbClearError()
+ }
}
}
}
@@ -87,4 +160,12 @@ class SettingsManager(
Toast.makeText(context, R.string.settings_db_export_error, LENGTH_LONG).show()
}
+ private fun onDbImportError() {
+ Toast.makeText(context, R.string.settings_db_import_error, LENGTH_LONG).show()
+ }
+
+ private fun onDbClearError() {
+ Toast.makeText(context, R.string.settings_db_clear_error, LENGTH_LONG).show()
+ }
+
}
diff --git a/wallet/src/main/java/net/taler/wallet/tip/AlreadyAcceptedFragment.kt b/wallet/src/main/java/net/taler/wallet/tip/AlreadyAcceptedFragment.kt
deleted file mode 100644
index 637a2da..0000000
--- a/wallet/src/main/java/net/taler/wallet/tip/AlreadyAcceptedFragment.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
- *
- * GNU Taler is free software; you can redistribute it and/or modify it under the
- * terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3, or (at your option) any later version.
- *
- * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-package net.taler.wallet.tip
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.fragment.app.Fragment
-import androidx.navigation.fragment.findNavController
-import net.taler.wallet.databinding.FragmentAlreadyAcceptedBinding
-
-/**
- * Display the message that the user already paid for the order
- * that the merchant is proposing.
- */
-class AlreadyAcceptedFragment : Fragment() {
-
- private lateinit var ui: FragmentAlreadyAcceptedBinding
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View {
- ui = FragmentAlreadyAcceptedBinding.inflate(inflater, container, false)
- return ui.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- ui.backButton.setOnClickListener {
- findNavController().navigateUp()
- }
- }
-
-}
diff --git a/wallet/src/main/java/net/taler/wallet/tip/PromptTipFragment.kt b/wallet/src/main/java/net/taler/wallet/tip/PromptTipFragment.kt
deleted file mode 100644
index b0f5a35..0000000
--- a/wallet/src/main/java/net/taler/wallet/tip/PromptTipFragment.kt
+++ /dev/null
@@ -1,158 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
- *
- * GNU Taler is free software; you can redistribute it and/or modify it under the
- * terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3, or (at your option) any later version.
- *
- * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-package net.taler.wallet.tip
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
-import androidx.navigation.fragment.findNavController
-import com.google.android.material.snackbar.Snackbar
-import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
-import net.taler.common.Amount
-import net.taler.common.fadeIn
-import net.taler.common.fadeOut
-import net.taler.wallet.MainViewModel
-import net.taler.wallet.R
-import net.taler.wallet.cleanExchange
-import net.taler.wallet.databinding.FragmentPromptTipBinding
-
-/**
- * Show a tip and ask the user to accept/decline.
- */
-class PromptTipFragment : Fragment() {
-
- private val model: MainViewModel by activityViewModels()
- private val tipManager by lazy { model.tipManager }
-
- private lateinit var ui: FragmentPromptTipBinding
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View {
- ui = FragmentPromptTipBinding.inflate(inflater, container, false)
- return ui.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- tipManager.tipStatus.observe(viewLifecycleOwner, ::onPaymentStatusChanged)
-
- }
-
- override fun onDestroy() {
- super.onDestroy()
- if (!requireActivity().isChangingConfigurations) {
- // tipManager.abortTip()
- }
- }
-
- private fun showLoading(show: Boolean) {
- model.showProgressBar.value = show
- if (show) {
- ui.progressBar.fadeIn()
- } else {
- ui.progressBar.fadeOut()
- }
- }
-
- private fun onPaymentStatusChanged(payStatus: TipStatus) = when (payStatus) {
- is TipStatus.Prepared -> {
- showLoading(false)
- showContent(
- amountRaw = payStatus.tipAmountRaw,
- amountEffective = payStatus.tipAmountEffective,
- exchange = payStatus.exchangeBaseUrl,
- merchant = payStatus.merchantBaseUrl
- )
- ui.confirmWithdrawButton.isEnabled = true
- ui.confirmWithdrawButton.setOnClickListener {
- tipManager.confirmTip(
- payStatus.walletTipId,
- payStatus.tipAmountRaw.currency
- )
- }
- }
- is TipStatus.Accepting -> {
- model.showProgressBar.value = true
- ui.confirmProgressBar.fadeIn()
- ui.confirmWithdrawButton.fadeOut()
- }
- is TipStatus.AlreadyAccepted -> {
- showLoading(false)
- tipManager.resetTipStatus()
- findNavController().navigate(R.id.action_promptTip_to_alreadyAccepted)
- }
- is TipStatus.Success -> {
- showLoading(false)
- tipManager.resetTipStatus()
- findNavController().navigate(R.id.action_promptTip_to_nav_main)
- model.showTransactions(payStatus.currency)
- Snackbar.make(requireView(), R.string.tip_received, LENGTH_LONG).show()
- }
- is TipStatus.Error -> {
- showLoading(false)
- ui.introView.text = getString(R.string.payment_error, payStatus.error)
- ui.introView.fadeIn()
- }
- is TipStatus.None -> {
- // No tip active
- showLoading(false)
- }
- is TipStatus.Loading -> {
- // Wait until loaded ...
- showLoading(true)
- }
- }
-
- private fun showContent(
- amountRaw: Amount,
- amountEffective: Amount,
- exchange: String,
- merchant: String,
- ) {
- model.showProgressBar.value = false
- ui.progressBar.fadeOut()
-
- ui.introView.fadeIn()
- ui.effectiveAmountView.text = amountEffective.toString()
- ui.effectiveAmountView.fadeIn()
-
- ui.chosenAmountLabel.fadeIn()
- ui.chosenAmountView.text = amountRaw.toString()
- ui.chosenAmountView.fadeIn()
-
- ui.feeLabel.fadeIn()
- ui.feeView.text =
- getString(R.string.amount_negative, (amountRaw - amountEffective).toString())
- ui.feeView.fadeIn()
-
- ui.exchangeIntroView.fadeIn()
- ui.withdrawExchangeUrl.text = cleanExchange(exchange)
- ui.withdrawExchangeUrl.fadeIn()
-
- ui.merchantIntroView.fadeIn()
- ui.withdrawMerchantUrl.text = cleanExchange(merchant)
- ui.withdrawMerchantUrl.fadeIn()
-
- ui.withdrawCard.fadeIn()
- }
-
-}
diff --git a/wallet/src/main/java/net/taler/wallet/tip/TipManager.kt b/wallet/src/main/java/net/taler/wallet/tip/TipManager.kt
deleted file mode 100644
index 5548687..0000000
--- a/wallet/src/main/java/net/taler/wallet/tip/TipManager.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
- *
- * GNU Taler is free software; you can redistribute it and/or modify it under the
- * terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3, or (at your option) any later version.
- *
- * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-package net.taler.wallet.tip
-
-import android.util.Log
-import androidx.annotation.UiThread
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import net.taler.common.Amount
-import net.taler.common.Timestamp
-import net.taler.wallet.TAG
-import net.taler.wallet.backend.TalerErrorInfo
-import net.taler.wallet.backend.WalletBackendApi
-import net.taler.wallet.tip.PrepareTipResponse.AlreadyAcceptedResponse
-import net.taler.wallet.tip.PrepareTipResponse.TipPossibleResponse
-
-sealed class TipStatus {
- object None : TipStatus()
- object Loading : TipStatus()
- data class Prepared(
- val walletTipId: String,
- val merchantBaseUrl: String,
- val exchangeBaseUrl: String,
- val expirationTimestamp: Timestamp,
- val tipAmountRaw: Amount,
- val tipAmountEffective: Amount,
- ) : TipStatus()
- object Accepting : TipStatus()
- data class AlreadyAccepted(
- val walletTipId: String,
- ) : TipStatus()
-
- // TODO bring user to fulfilment URI (not yet in wallet API)
- data class Error(val error: String) : TipStatus()
- data class Success(val currency: String) : TipStatus()
-}
-
-class TipManager(
- private val api: WalletBackendApi,
- private val scope: CoroutineScope,
-) {
-
- private val mTipStatus = MutableLiveData<TipStatus>(TipStatus.None)
- internal val tipStatus: LiveData<TipStatus> = mTipStatus
-
- @UiThread
- fun prepareTip(url: String) = scope.launch {
- mTipStatus.value = TipStatus.Loading
- api.request("prepareTip", PrepareTipResponse.serializer()) {
- put("talerTipUri", url)
- }.onError {
- handleError("prepareTip", it)
- }.onSuccess { response ->
- mTipStatus.value = when (response) {
- is TipPossibleResponse -> response.toTipStatusPrepared()
- is AlreadyAcceptedResponse -> TipStatus.AlreadyAccepted(
- response.walletTipId
- )
- }
- }
- }
-
- fun confirmTip(tipId: String, currency: String) = scope.launch {
- mTipStatus.value = TipStatus.Accepting
- api.request("acceptTip", ConfirmTipResult.serializer()) {
- put("walletTipId", tipId)
- }.onError {
- handleError("acceptTip", it)
- }.onSuccess {
- mTipStatus.postValue(TipStatus.Success(currency))
- }
- }
-
- @UiThread
- fun resetTipStatus() {
- mTipStatus.value = TipStatus.None
- }
-
- private fun handleError(operation: String, error: TalerErrorInfo) {
- Log.e(TAG, "got $operation error result $error")
- mTipStatus.value = TipStatus.Error(error.userFacingMsg)
- }
-
-}
diff --git a/wallet/src/main/java/net/taler/wallet/tip/TipResponses.kt b/wallet/src/main/java/net/taler/wallet/tip/TipResponses.kt
deleted file mode 100644
index b0f6273..0000000
--- a/wallet/src/main/java/net/taler/wallet/tip/TipResponses.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2020 Taler Systems S.A.
- *
- * GNU Taler is free software; you can redistribute it and/or modify it under the
- * terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3, or (at your option) any later version.
- *
- * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-package net.taler.wallet.tip
-
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonClassDiscriminator
-import net.taler.common.Amount
-import net.taler.common.Timestamp
-
-@OptIn(ExperimentalSerializationApi::class)
-@Serializable
-@JsonClassDiscriminator("accepted")
-sealed class PrepareTipResponse {
-
- @Serializable
- @SerialName("false")
- data class TipPossibleResponse(
- val walletTipId: String,
- val merchantBaseUrl: String,
- val exchangeBaseUrl: String,
- val expirationTimestamp: Timestamp,
- val tipAmountRaw: Amount,
- val tipAmountEffective: Amount,
- ) : PrepareTipResponse() {
- fun toTipStatusPrepared() = TipStatus.Prepared(
- walletTipId = walletTipId,
- merchantBaseUrl = merchantBaseUrl,
- exchangeBaseUrl = exchangeBaseUrl,
- expirationTimestamp = expirationTimestamp,
- tipAmountEffective = tipAmountEffective,
- tipAmountRaw = tipAmountRaw,
- )
- }
-
- @Serializable
- @SerialName("true")
- data class AlreadyAcceptedResponse(
- val walletTipId: String,
- ) : PrepareTipResponse()
-}
-
-@Serializable
-class ConfirmTipResult
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/ActionButtonComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/ActionButtonComposable.kt
new file mode 100644
index 0000000..4e4bbe0
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/transactions/ActionButtonComposable.kt
@@ -0,0 +1,116 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.transactions
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountBalance
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import net.taler.wallet.R
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import net.taler.wallet.transactions.TransactionMinorState.BankConfirmTransfer
+import net.taler.wallet.transactions.TransactionMinorState.ExchangeWaitReserve
+import net.taler.wallet.transactions.TransactionMinorState.KycRequired
+
+interface ActionListener {
+ enum class Type {
+ COMPLETE_KYC,
+ CONFIRM_WITH_BANK,
+ CONFIRM_MANUAL
+ }
+
+ fun onActionButtonClicked(tx: Transaction, type: Type)
+}
+
+@Composable
+fun ActionButton(
+ modifier: Modifier = Modifier,
+ tx: TransactionWithdrawal,
+ listener: ActionListener,
+) {
+ if (tx.txState.major == Pending) {
+ when (tx.txState.minor) {
+ KycRequired -> KycButton(modifier, tx, listener)
+ BankConfirmTransfer -> ConfirmBankButton(modifier, tx, listener)
+ ExchangeWaitReserve -> ConfirmManualButton(modifier, tx, listener)
+ else -> {}
+ }
+ }
+}
+
+@Composable
+private fun KycButton(
+ modifier: Modifier = Modifier,
+ tx: TransactionWithdrawal,
+ listener: ActionListener,
+) {
+ Button(
+ onClick = { listener.onActionButtonClicked(tx, ActionListener.Type.COMPLETE_KYC) },
+ modifier = modifier,
+ ) {
+ Text(stringResource(R.string.transaction_action_kyc))
+ }
+}
+
+@Composable
+private fun ConfirmBankButton(
+ modifier: Modifier = Modifier,
+ tx: TransactionWithdrawal,
+ listener: ActionListener,
+) {
+ Button(
+ onClick = { listener.onActionButtonClicked(tx, ActionListener.Type.CONFIRM_WITH_BANK) },
+ modifier = modifier,
+ ) {
+ val label = stringResource(R.string.withdraw_button_confirm_bank)
+ Icon(
+ Icons.Default.AccountBalance,
+ label,
+ modifier = Modifier.size(ButtonDefaults.IconSize)
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(label)
+ }
+}
+
+@Composable
+private fun ConfirmManualButton(
+ modifier: Modifier = Modifier,
+ tx: TransactionWithdrawal,
+ listener: ActionListener,
+) {
+ Button(
+ onClick = { listener.onActionButtonClicked(tx, ActionListener.Type.CONFIRM_MANUAL) },
+ modifier = modifier,
+ ) {
+ val label = stringResource(R.string.withdraw_manual_ready_details_intro)
+ Icon(
+ Icons.Default.AccountBalance,
+ label,
+ modifier = Modifier.size(ButtonDefaults.IconSize)
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(label)
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/DeleteTransactionComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/DeleteTransactionComposable.kt
deleted file mode 100644
index 7b69e2b..0000000
--- a/wallet/src/main/java/net/taler/wallet/transactions/DeleteTransactionComposable.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2022 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.transactions
-
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.Button
-import androidx.compose.material.ButtonDefaults
-import androidx.compose.material.Icon
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import net.taler.wallet.R
-
-@Composable
-fun DeleteTransactionComposable(onDelete: () -> Unit) {
- Button(
- modifier = Modifier.padding(16.dp),
- colors = ButtonDefaults.buttonColors(backgroundColor = colorResource(R.color.red)),
- onClick = onDelete,
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- painter = painterResource(id = R.drawable.ic_delete),
- contentDescription = null,
- tint = Color.White,
- )
- Text(
- modifier = Modifier.padding(start = 8.dp),
- text = stringResource(R.string.transactions_delete),
- color = Color.White,
- )
- }
- }
-}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/ErrorTransactionComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/ErrorTransactionComposable.kt
new file mode 100644
index 0000000..ea875d7
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/transactions/ErrorTransactionComposable.kt
@@ -0,0 +1,116 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.transactions
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.sp
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.compose.copyToClipBoard
+
+@Composable
+fun ErrorTransactionButton(
+ modifier: Modifier = Modifier,
+ error: TalerErrorInfo,
+) {
+ val showDialog = remember { mutableStateOf(false) }
+
+ if (showDialog.value) {
+ @Suppress("OPT_IN_USAGE")
+ val json = Json {
+ prettyPrint = true
+ prettyPrintIndent = " "
+ }
+ val message = json.encodeToString(error)
+ AlertDialog(
+ onDismissRequest = {
+ showDialog.value = false
+ },
+ title = {
+ Text(stringResource(R.string.nav_error))
+ },
+ text = {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier.verticalScroll(scrollState),
+ ) {
+ Text(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 10.sp,
+ text = message,
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ showDialog.value = false
+ }) {
+ Text(stringResource(R.string.close))
+ }
+ },
+ confirmButton = {
+ val context = LocalContext.current
+ TextButton(onClick = {
+ copyToClipBoard(context, context.getString(R.string.nav_error), message)
+ }) {
+ Text(stringResource(R.string.copy))
+ }
+ })
+ }
+
+ Button(
+ modifier = modifier,
+ colors = ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onError,
+ containerColor = MaterialTheme.colorScheme.error,
+ ),
+ onClick = {
+ showDialog.value = true
+ }
+ ) {
+ val label = stringResource(R.string.nav_error)
+ Icon(
+ imageVector = Icons.Default.Error,
+ contentDescription = label,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(label)
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt
index fa30f5c..3b686a6 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt
@@ -32,18 +32,25 @@ import androidx.recyclerview.selection.SelectionTracker
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
-import com.google.android.material.button.MaterialButton
+import net.taler.common.CurrencySpecification
import net.taler.common.exhaustive
import net.taler.common.toRelativeTime
import net.taler.wallet.R
-import net.taler.wallet.handleKyc
+import net.taler.wallet.getThemeColor
import net.taler.wallet.transactions.TransactionAdapter.TransactionViewHolder
+import net.taler.wallet.transactions.TransactionMajorState.Aborted
+import net.taler.wallet.transactions.TransactionMajorState.Failed
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import net.taler.wallet.transactions.TransactionMinorState.BankConfirmTransfer
+import net.taler.wallet.transactions.TransactionMinorState.KycRequired
internal class TransactionAdapter(
- private val listener: OnTransactionClickListener
+ private val listener: OnTransactionClickListener,
) : Adapter<TransactionViewHolder>() {
private var transactions: List<Transaction> = ArrayList()
+ private var currencySpec: CurrencySpecification? = null
+
lateinit var tracker: SelectionTracker<String>
val keyProvider = TransactionKeyProvider()
@@ -64,6 +71,11 @@ internal class TransactionAdapter(
holder.bind(transaction, tracker.isSelected(transaction.transactionId))
}
+ fun setCurrencySpec(spec: CurrencySpecification?) {
+ this.currencySpec = spec
+ this.notifyDataSetChanged()
+ }
+
fun update(updatedTransactions: List<Transaction>) {
this.transactions = updatedTransactions
this.notifyDataSetChanged()
@@ -80,13 +92,13 @@ internal class TransactionAdapter(
private val icon: ImageView = v.findViewById(R.id.icon)
private val title: TextView = v.findViewById(R.id.title)
private val extraInfoView: TextView = v.findViewById(R.id.extraInfoView)
- private val actionButton: MaterialButton = v.findViewById(R.id.actionButton)
private val time: TextView = v.findViewById(R.id.time)
private val amount: TextView = v.findViewById(R.id.amount)
private val pendingView: TextView = v.findViewById(R.id.pendingView)
private val amountColor = amount.currentTextColor
- private val red = getColor(context, R.color.red)
+ private val extraInfoColor = extraInfoView.currentTextColor
+ private val red = context.getThemeColor(R.attr.colorError)
private val green = getColor(context, R.color.green)
fun bind(transaction: Transaction, selected: Boolean) {
@@ -98,54 +110,100 @@ internal class TransactionAdapter(
}
title.text = transaction.getTitle(context)
bindExtraInfo(transaction)
- bindActionButton(transaction)
time.text = transaction.timestamp.ms.toRelativeTime(context)
bindAmount(transaction)
- pendingView.visibility = if (transaction.pending) VISIBLE else GONE
- val bgColor = getColor(context,
+ pendingView.visibility = if (transaction.txState.major == Pending) VISIBLE else GONE
+ val bgColor = getColor(
+ context,
if (selected) R.color.selectedBackground
- else android.R.color.transparent)
+ else android.R.color.transparent
+ )
root.setBackgroundColor(bgColor)
}
private fun bindExtraInfo(transaction: Transaction) {
- if (transaction.error != null) {
- extraInfoView.text =
- context.getString(R.string.payment_error, transaction.error!!.userFacingMsg)
- extraInfoView.setTextColor(red)
- extraInfoView.visibility = VISIBLE
- } else if (transaction is TransactionWithdrawal && !transaction.confirmed) {
- extraInfoView.setText(R.string.withdraw_waiting_confirm)
- extraInfoView.setTextColor(amountColor)
- extraInfoView.visibility = VISIBLE
- } else if (transaction is TransactionPayment && transaction.status != PaymentStatus.Paid && transaction.status != PaymentStatus.Accepted) {
- extraInfoView.setText(if (transaction.status == PaymentStatus.Aborted) R.string.payment_aborted else R.string.payment_failed)
- extraInfoView.setTextColor(amountColor)
- extraInfoView.visibility = VISIBLE
- } else {
- extraInfoView.visibility = GONE
- }
- }
+ when {
+ // Goes first so it always shows errors when present
+ transaction.error != null -> {
+ extraInfoView.text =
+ context.getString(R.string.payment_error, transaction.error!!.userFacingMsg)
+ extraInfoView.setTextColor(red)
+ extraInfoView.visibility = VISIBLE
+ }
+
+ transaction.txState.major == Aborted -> {
+ extraInfoView.setText(R.string.payment_aborted)
+ extraInfoView.setTextColor(red)
+ extraInfoView.visibility = VISIBLE
+ }
+
+ transaction.txState.major == Failed -> {
+ extraInfoView.setText(R.string.payment_failed)
+ extraInfoView.setTextColor(red)
+ extraInfoView.visibility = VISIBLE
+ }
+
+ transaction.txState.major == Pending -> when (transaction.txState.minor) {
+ BankConfirmTransfer -> {
+ extraInfoView.setText(R.string.withdraw_waiting_confirm)
+ extraInfoView.setTextColor(amountColor)
+ extraInfoView.visibility = VISIBLE
+ }
+ KycRequired -> {
+ extraInfoView.setText(R.string.transaction_action_kyc)
+ extraInfoView.setTextColor(amountColor)
+ extraInfoView.visibility = VISIBLE
+ }
+ else -> extraInfoView.visibility = GONE
+ }
+
+ transaction is TransactionWithdrawal && !transaction.confirmed -> {
+ extraInfoView.setText(R.string.withdraw_waiting_confirm)
+ extraInfoView.setTextColor(amountColor)
+ extraInfoView.visibility = VISIBLE
+ }
+
+ transaction is TransactionPeerPushCredit && transaction.info.summary != null -> {
+ extraInfoView.text = transaction.info.summary
+ extraInfoView.setTextColor(extraInfoColor)
+ extraInfoView.visibility = VISIBLE
+ }
+
+ transaction is TransactionPeerPushDebit && transaction.info.summary != null -> {
+ extraInfoView.text = transaction.info.summary
+ extraInfoView.setTextColor(extraInfoColor)
+ extraInfoView.visibility = VISIBLE
+ }
- private fun bindActionButton(transaction: Transaction) {
- actionButton.visibility = transaction.handleKyc({ GONE }) {
- actionButton.setOnClickListener { listener.onActionButtonClicked(transaction) }
- actionButton.setText(R.string.transaction_action_kyc)
- VISIBLE
+ transaction is TransactionPeerPullCredit && transaction.info.summary != null -> {
+ extraInfoView.text = transaction.info.summary
+ extraInfoView.setTextColor(extraInfoColor)
+ extraInfoView.visibility = VISIBLE
+ }
+
+ transaction is TransactionPeerPullDebit && transaction.info.summary != null -> {
+ extraInfoView.text = transaction.info.summary
+ extraInfoView.setTextColor(extraInfoColor)
+ extraInfoView.visibility = VISIBLE
+ }
+
+ else -> extraInfoView.visibility = GONE
}
}
private fun bindAmount(transaction: Transaction) {
- val amountStr = transaction.amountEffective.amountStr
+ val amountStr = transaction.amountEffective.withSpec(currencySpec).toString(showSymbol = false)
when (transaction.amountType) {
AmountType.Positive -> {
amount.text = context.getString(R.string.amount_positive, amountStr)
- amount.setTextColor(if (transaction.pending) amountColor else green)
+ amount.setTextColor(if (transaction.txState.major == Pending) amountColor else green)
}
+
AmountType.Negative -> {
amount.text = context.getString(R.string.amount_negative, amountStr)
- amount.setTextColor(if (transaction.pending) amountColor else red)
+ amount.setTextColor(if (transaction.txState.major == Pending) amountColor else red)
}
+
AmountType.Neutral -> {
amount.text = amountStr
amount.setTextColor(amountColor)
@@ -165,12 +223,13 @@ internal class TransactionAdapter(
internal class TransactionLookup(
private val list: RecyclerView,
- private val adapter: TransactionAdapter
+ private val adapter: TransactionAdapter,
) : ItemDetailsLookup<String>() {
override fun getItemDetails(e: MotionEvent): ItemDetails<String>? {
list.findChildViewUnder(e.x, e.y)?.let { view ->
val holder = list.getChildViewHolder(view)
val position = holder.bindingAdapterPosition
+ if (position < 0) return null
return object : ItemDetails<String>() {
override fun getPosition(): Int = position
override fun getSelectionKey(): String = adapter.keyProvider.getKey(position)
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt
index 60f9b20..d2be3cf 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt
@@ -20,6 +20,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.ComposeView
import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.deposit.TransactionDepositComposable
@@ -33,9 +34,13 @@ class TransactionDepositFragment : TransactionDetailFragment() {
): View = ComposeView(requireContext()).apply {
setContent {
TalerSurface {
- val t = transaction ?: error("No transaction")
- TransactionDepositComposable(t as TransactionDeposit) {
- onDeleteButtonClicked(t)
+ val t = transactionManager.selectedTransaction.observeAsState().value
+ if (t is TransactionDeposit) TransactionDepositComposable(
+ t = t,
+ devMode = devMode,
+ spec = balanceManager.getSpecForCurrency(t.amountRaw.currency),
+ ) {
+ onTransitionButtonClicked(t, 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
index ed42066..09ca05b 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt
@@ -16,105 +16,151 @@
package net.taler.wallet.transactions
-import android.content.Intent
-import android.net.Uri
import android.os.Bundle
-import android.view.Menu
-import android.view.MenuInflater
-import android.view.MenuItem
-import android.widget.TextView
-import androidx.annotation.StringRes
-import androidx.appcompat.app.AlertDialog
+import android.util.Log
+import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
-import net.taler.common.Amount
-import net.taler.common.startActivitySafe
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import net.taler.common.showError
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
+import net.taler.wallet.TAG
+import net.taler.wallet.showError
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Delete
+import net.taler.wallet.transactions.TransactionAction.Fail
+import net.taler.wallet.transactions.TransactionAction.Resume
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
abstract class TransactionDetailFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
- private val transactionManager by lazy { model.transactionManager }
- protected val transaction: Transaction? get() = transactionManager.selectedTransaction
+ protected val transactionManager by lazy { model.transactionManager }
+ protected val balanceManager by lazy { model.balanceManager }
+ protected val devMode get() = model.devMode.value == true
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(model.devMode.value == true)
- }
-
- @Deprecated("Deprecated in Java")
- override fun onActivityCreated(savedInstanceState: Bundle?) {
- super.onActivityCreated(savedInstanceState)
- requireActivity().apply {
- transaction?.generalTitleRes?.let {
- title = getString(it)
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ transactionManager.selectedTransaction.observe(viewLifecycleOwner) {
+ requireActivity().apply {
+ it?.generalTitleRes?.let {
+ title = getString(it)
+ }
}
}
}
- @Deprecated("Deprecated in Java")
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.transactions_detail, menu)
+ private fun dialogTitle(t: TransactionAction): Int = when (t) {
+ Delete -> R.string.transactions_delete_dialog_title
+ Abort -> R.string.transactions_abort_dialog_title
+ Fail -> R.string.transactions_fail_dialog_title
+ else -> error("unsupported action: $t")
}
- @Deprecated("Deprecated in Java")
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- else -> super.onOptionsItemSelected(item)
- }
+ private fun dialogMessage(t: TransactionAction): Int = when (t) {
+ Delete -> R.string.transactions_delete_dialog_message
+ Abort -> R.string.transactions_abort_dialog_message
+ Fail -> R.string.transactions_fail_dialog_message
+ else -> error("unsupported action: $t")
}
- protected fun bindOrderAndFee(
- orderSummaryView: TextView,
- orderAmountView: TextView,
- orderIdView: TextView,
- feeView: TextView,
- info: TransactionInfo,
- raw: Amount,
- fee: Amount,
- ) {
- orderAmountView.text = raw.toString()
- feeView.text = getString(R.string.amount_negative, fee.toString())
- orderSummaryView.text = if (info.fulfillmentMessage == null) {
- info.summary
- } else {
- "${info.summary}\n\n${info.fulfillmentMessage}"
- }
- if (info.fulfillmentUrl?.startsWith("http", ignoreCase = true) == true) {
- val i = Intent().apply {
- data = Uri.parse(info.fulfillmentUrl)
- }
- orderSummaryView.setOnClickListener { requireContext().startActivitySafe(i) }
- }
- orderIdView.text = getString(R.string.transaction_order_id, info.orderId)
+ private fun dialogButton(t: TransactionAction): Int = when (t) {
+ Delete -> R.string.transactions_delete
+ Abort -> R.string.transactions_abort
+ Fail -> R.string.transactions_fail
+ else -> error("unsupported")
+ }
+
+ protected fun onTransitionButtonClicked(t: Transaction, ta: TransactionAction) = when (ta) {
+ Delete -> showDialog(ta) { deleteTransaction(t) }
+ Abort -> showDialog(ta) { abortTransaction(t) }
+ Fail -> showDialog(ta) { failTransaction(t) }
+ Retry -> retryTransaction(t)
+ Suspend -> suspendTransaction(t)
+ Resume -> resumeTransaction(t)
}
- @StringRes
- protected open val deleteDialogTitle = R.string.transactions_delete
- @StringRes
- protected open val deleteDialogMessage = R.string.transactions_delete_dialog_message
- @StringRes
- protected open val deleteDialogButton = R.string.transactions_delete
-
- protected fun onDeleteButtonClicked(t: Transaction) {
- AlertDialog.Builder(requireContext(), R.style.DialogTheme)
- .setTitle(deleteDialogTitle)
- .setMessage(deleteDialogMessage)
+ private fun showDialog(tt: TransactionAction, onAction: () -> Unit) {
+ MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3)
+ .setTitle(dialogTitle(tt))
+ .setMessage(dialogMessage(tt))
.setNeutralButton(R.string.cancel) { dialog, _ ->
dialog.cancel()
}
- .setNegativeButton(deleteDialogButton) { dialog, _ ->
- deleteTransaction(t)
+ .setNegativeButton(dialogButton(tt)) { dialog, _ ->
+ onAction()
dialog.dismiss()
}
.show()
}
private fun deleteTransaction(t: Transaction) {
- transactionManager.deleteTransaction(t.transactionId)
+ transactionManager.deleteTransaction(t.transactionId) {
+ Log.e(TAG, "Error deleteTransaction $it")
+ if (model.devMode.value == true) {
+ showError(it)
+ } else {
+ showError(it.userFacingMsg)
+ }
+ }
findNavController().popBackStack()
}
+ private fun retryTransaction(t: Transaction) {
+ transactionManager.retryTransaction(t.transactionId) {
+ Log.e(TAG, "Error retryTransaction $it")
+ if (model.devMode.value == true) {
+ showError(it)
+ } else {
+ showError(it.userFacingMsg)
+ }
+ }
+ }
+
+ private fun abortTransaction(t: Transaction) {
+ transactionManager.abortTransaction(t.transactionId) {
+ Log.e(TAG, "Error abortTransaction $it")
+ if (model.devMode.value == true) {
+ showError(it)
+ } else {
+ showError(it.userFacingMsg)
+ }
+ }
+ }
+
+ private fun failTransaction(t: Transaction) {
+ transactionManager.failTransaction(t.transactionId) {
+ Log.e(TAG, "Error failTransaction $it")
+ if (model.devMode.value == true) {
+ showError(it)
+ } else {
+ showError(it.userFacingMsg)
+ }
+ }
+ }
+
+ private fun suspendTransaction(t: Transaction) {
+ transactionManager.suspendTransaction(t.transactionId) {
+ Log.e(TAG, "Error suspendTransaction $it")
+ if (model.devMode.value == true) {
+ showError(it)
+ } else {
+ showError(it.userFacingMsg)
+ }
+ }
+ }
+
+ private fun resumeTransaction(t: Transaction) {
+ transactionManager.resumeTransaction(t.transactionId) {
+ Log.e(TAG, "Error resumeTransaction $it")
+ if (model.devMode.value == true) {
+ showError(it)
+ } else {
+ showError(it.userFacingMsg)
+ }
+ }
+ }
}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt
new file mode 100644
index 0000000..ad5e19c
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt
@@ -0,0 +1,64 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2022 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.transactions
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.unit.dp
+import net.taler.wallet.compose.TalerSurface
+
+class TransactionDummyFragment : TransactionDetailFragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View = ComposeView(requireContext()).apply {
+ setContent {
+ TalerSurface {
+ val t = transactionManager.selectedTransaction.observeAsState(null).value
+ if (t is DummyTransaction) TransactionDummyComposable(t)
+ }
+ }
+ }
+}
+
+@Composable
+fun TransactionDummyComposable(t: DummyTransaction) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .verticalScroll(scrollState),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ ErrorTransactionButton(error = t.error)
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLinkComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLinkComposable.kt
new file mode 100644
index 0000000..e8fca0f
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLinkComposable.kt
@@ -0,0 +1,86 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.transactions
+
+import android.R
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.getAttrColor
+
+@Composable
+// FIXME this assumes that it is used in a column and applies its own padding, not really re-usable
+fun TransactionLinkComposable(label: String, info: String, onClick: () -> Unit) {
+ Text(
+ modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ val context = LocalContext.current
+ val linkColor = Color(context.getAttrColor(R.attr.textColorLink))
+ val annotatedString = buildAnnotatedString {
+ pushStringAnnotation(tag = "url", annotation = info)
+ withStyle(style = SpanStyle(color = linkColor)) {
+ append(info)
+ }
+ pop()
+ }
+ ClickableText(
+ modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp),
+ text = annotatedString,
+ style = TextStyle(fontSize = 24.sp, textAlign = TextAlign.Center),
+ ) { offset ->
+ annotatedString.getStringAnnotations(
+ tag = "url",
+ start = offset,
+ end = offset,
+ ).firstOrNull()?.let {
+ onClick()
+ }
+ }
+}
+
+@Preview
+@Composable
+fun TransactionLinkComposablePreview() {
+ TalerSurface {
+ Column(
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ TransactionLinkComposable(
+ label = "This is a label",
+ info = "This is some fulfillment message"
+ ) {}
+ }
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt
new file mode 100644
index 0000000..2c95880
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt
@@ -0,0 +1,163 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.transactions
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.common.CurrencySpecification
+import net.taler.common.Timestamp
+import net.taler.common.toAbsoluteTime
+import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.transactions.LossEventType.DenomExpired
+import net.taler.wallet.transactions.LossEventType.DenomUnoffered
+import net.taler.wallet.transactions.LossEventType.DenomVanished
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+
+class TransactionLossFragment: TransactionDetailFragment() {
+ val scope get() = transactionManager.selectedScope
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View = ComposeView(requireContext()).apply {
+ setContent {
+ val t = transactionManager.selectedTransaction.observeAsState().value
+ val spec = scope?.let { balanceManager.getSpecForScopeInfo(it) }
+
+ TalerSurface {
+ if (t is TransactionDenomLoss) {
+ TransitionLossComposable(t, devMode, spec) {
+ onTransitionButtonClicked(t, it)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TransitionLossComposable(
+ t: TransactionDenomLoss,
+ devMode: Boolean,
+ spec: CurrencySpecification?,
+ onTransition: (t: TransactionAction) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ modifier = Modifier.padding(16.dp),
+ text = t.timestamp.ms.toAbsoluteTime(context).toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_lost),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+
+ TransactionInfoComposable(
+ label = stringResource(id = R.string.loss_reason),
+ info = stringResource(
+ when(t.lossEventType) {
+ DenomExpired -> R.string.loss_reason_expired
+ DenomVanished -> R.string.loss_reason_vanished
+ DenomUnoffered -> R.string.loss_reason_unoffered
+ }
+ )
+ )
+
+ TransitionsComposable(t, devMode, onTransition)
+
+ if (devMode && t.error != null) {
+ ErrorTransactionButton(error = t.error)
+ }
+ }
+}
+
+fun previewLossTransaction(lossEventType: LossEventType) =
+ TransactionDenomLoss(
+ transactionId = "transactionId",
+ timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
+ txState = TransactionState(Pending),
+ txActions = listOf(Retry, Suspend, Abort),
+ amountRaw = Amount.fromString("TESTKUDOS", "0.3"),
+ amountEffective = Amount.fromString("TESTKUDOS", "0.3"),
+ error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED),
+ lossEventType = lossEventType,
+ )
+
+@Composable
+@Preview
+fun TransitionLossComposableExpiredPreview() {
+ val t = previewLossTransaction(DenomExpired)
+ Surface {
+ TransitionLossComposable(t, true, null) {}
+ }
+}
+
+@Composable
+@Preview
+fun TransitionLossComposableVanishedPreview() {
+ val t = previewLossTransaction(DenomVanished)
+ Surface {
+ TransitionLossComposable(t, true, null) {}
+ }
+}
+
+@Composable
+@Preview
+fun TransactionLossComposableUnofferedPreview() {
+ val t = previewLossTransaction(DenomUnoffered)
+ Surface {
+ TransitionLossComposable(t, true, null) {}
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt
index bbae22b..d0dec41 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt
@@ -23,12 +23,19 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.switchMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
import net.taler.wallet.TAG
+import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.backend.WalletBackendApi
+import net.taler.wallet.balances.ScopeInfo
+import net.taler.wallet.transactions.TransactionAction.Delete
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import org.json.JSONObject
import java.util.LinkedList
sealed class TransactionsResult {
- class Error(val msg: String) : TransactionsResult()
+ class Error(val error: TalerErrorInfo) : TransactionsResult()
class Success(val transactions: List<Transaction>) : TransactionsResult()
}
@@ -40,69 +47,174 @@ class TransactionManager(
private val mProgress = MutableLiveData<Boolean>()
val progress: LiveData<Boolean> = mProgress
- var selectedCurrency: String? = null
- var selectedTransaction: Transaction? = null
+ // FIXME if the app gets killed, this will not be restored and thus be unexpected null
+ // we should keep this in a savable, maybe using Hilt and SavedStateViewModel
+ var selectedScope: ScopeInfo? = null
val searchQuery = MutableLiveData<String>(null)
- private val allTransactions = HashMap<String, List<Transaction>>()
- private val mTransactions = HashMap<String, MutableLiveData<TransactionsResult>>()
+ private val mSelectedTransaction = MutableLiveData<Transaction?>(null)
+ val selectedTransaction: LiveData<Transaction?> = mSelectedTransaction
+ private val allTransactions = HashMap<ScopeInfo, List<Transaction>>()
+ private val mTransactions = HashMap<ScopeInfo, MutableLiveData<TransactionsResult>>()
val transactions: LiveData<TransactionsResult>
@UiThread
get() = searchQuery.switchMap { query ->
- val currency = selectedCurrency
- check(currency != null) { "Did not select currency before getting transactions" }
+ val scopeInfo = selectedScope
+ check(scopeInfo != null) { "Did not select scope before getting transactions" }
loadTransactions(query)
- mTransactions[currency]!! // non-null because filled in [loadTransactions]
+ mTransactions[scopeInfo]!! // non-null because filled in [loadTransactions]
}
@UiThread
fun loadTransactions(searchQuery: String? = null) = scope.launch {
- val currency = selectedCurrency ?: return@launch
- val liveData = mTransactions.getOrPut(currency) { MutableLiveData() }
- if (searchQuery == null && allTransactions.containsKey(currency)) {
- liveData.value = TransactionsResult.Success(allTransactions[currency]!!)
+ val scopeInfo = selectedScope ?: return@launch
+ val liveData = mTransactions.getOrPut(scopeInfo) { MutableLiveData() }
+ if (searchQuery == null && allTransactions.containsKey(scopeInfo)) {
+ liveData.value = TransactionsResult.Success(allTransactions[scopeInfo]!!)
}
if (liveData.value == null) mProgress.value = true
api.request("getTransactions", Transactions.serializer()) {
if (searchQuery != null) put("search", searchQuery)
- put("currency", currency)
+ put("scopeInfo", JSONObject(Json.encodeToString(scopeInfo)))
}.onError {
- liveData.postValue(TransactionsResult.Error(it.userFacingMsg))
+ liveData.postValue(TransactionsResult.Error(it))
mProgress.postValue(false)
}.onSuccess { result ->
val transactions = LinkedList(result.transactions)
- // TODO remove when fixed in wallet-core
- val comparator = compareBy<Transaction>(
- { it.pending },
- { it.timestamp.ms },
- { it.transactionId }
- )
+ val comparator = compareBy<Transaction> { it.txState.major == Pending }
transactions.sortWith(comparator)
transactions.reverse() // show latest first
mProgress.value = false
liveData.value = TransactionsResult.Success(transactions)
- // update all transactions on UiThread if there was a currency
- if (searchQuery == null) allTransactions[currency] = transactions
+ // update selected transaction on UiThread (if it exists)
+ val selected = selectedTransaction.value
+ if (selected != null) transactions.find {
+ it.transactionId == selected.transactionId
+ }?.let {
+ mSelectedTransaction.value = it
+ }
+
+ // update all transactions on UiThread if there was a scope info
+ if (searchQuery == null) allTransactions[scopeInfo] = transactions
+ }
+ }
+
+ /**
+ * Returns true if given [transactionId] was found and selected, false otherwise.
+ */
+ @UiThread
+ suspend fun selectTransaction(transactionId: String): Boolean {
+ var transaction: Transaction? = null
+ api.request("getTransactionById", Transaction.serializer()) {
+ put("transactionId", transactionId)
+ }.onError {
+ Log.e(TAG, "Error getting transaction $it")
+ }.onSuccess { result ->
+ transaction = result
+ }
+ return if (transaction != null) {
+ mSelectedTransaction.value = transaction
+ true
+ } else {
+ false
}
}
- fun deleteTransaction(transactionId: String) = scope.launch {
- api.request<Unit>("deleteTransaction") {
+ suspend fun getTransactionById(transactionId: String): Transaction? {
+ var transaction: Transaction? = null
+ api.request("getTransactionById", Transaction.serializer()) {
put("transactionId", transactionId)
}.onError {
- Log.e(TAG, "Error deleteTransaction $it")
- }.onSuccess {
- // re-load transactions as our list is stale otherwise
- loadTransactions()
+ Log.e(TAG, "Error getting transaction $it")
+ }.onSuccess { result ->
+ transaction = result
}
+ return transaction
+ }
+
+ fun selectTransaction(transaction: Transaction) {
+ mSelectedTransaction.postValue(transaction)
}
- fun deleteTransactions(transactionIds: List<String>) {
- transactionIds.forEach { id ->
- deleteTransaction(id)
+ fun deleteTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) =
+ scope.launch {
+ api.request<Unit>("deleteTransaction") {
+ put("transactionId", transactionId)
+ }.onError {
+ onError(it)
+ }.onSuccess {
+ // re-load transactions as our list is stale otherwise
+ loadTransactions()
+ }
+ }
+
+ fun retryTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) =
+ scope.launch {
+ api.request<Unit>("retryTransaction") {
+ put("transactionId", transactionId)
+ }.onError {
+ onError(it)
+ }.onSuccess {
+ loadTransactions()
+ }
+ }
+
+ fun abortTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) =
+ scope.launch {
+ api.request<Unit>("abortTransaction") {
+ put("transactionId", transactionId)
+ }.onError {
+ onError(it)
+ }.onSuccess {
+ loadTransactions()
+ }
+ }
+
+ fun failTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) =
+ scope.launch {
+ api.request<Unit>("failTransaction") {
+ put("transactionId", transactionId)
+ }.onError {
+ onError(it)
+ }.onSuccess {
+ loadTransactions()
+ }
+ }
+
+ fun suspendTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) =
+ scope.launch {
+ api.request<Unit>("suspendTransaction") {
+ put("transactionId", transactionId)
+ }.onError {
+ onError(it)
+ }.onSuccess {
+ loadTransactions()
+ }
+ }
+
+ fun resumeTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) =
+ scope.launch {
+ api.request<Unit>("resumeTransaction") {
+ put("transactionId", transactionId)
+ }.onError {
+ onError(it)
+ }.onSuccess {
+ loadTransactions()
+ }
+ }
+
+ fun deleteTransactions(transactionIds: List<String>, onError: (it: TalerErrorInfo) -> Unit) {
+ allTransactions[selectedScope]?.filter { transaction ->
+ transaction.transactionId in transactionIds
+ }?.forEach { toBeDeletedTx ->
+ if (Delete in toBeDeletedTx.txActions) {
+ deleteTransaction(toBeDeletedTx.transactionId) {
+ onError(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
index e2e4f9f..596a4a9 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt
@@ -20,40 +20,32 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import net.taler.common.toAbsoluteTime
-import net.taler.wallet.databinding.FragmentTransactionPaymentBinding
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.platform.ComposeView
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.launchInAppBrowser
+import net.taler.wallet.payment.TransactionPaymentComposable
class TransactionPaymentFragment : TransactionDetailFragment() {
- private lateinit var ui: FragmentTransactionPaymentBinding
-
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
- ): View {
- ui = FragmentTransactionPaymentBinding.inflate(inflater, container, false)
- return ui.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- val t = transaction as TransactionPayment
- ui.timeView.text = t.timestamp.ms.toAbsoluteTime(requireContext())
-
- ui.amountPaidWithFeesView.text = t.amountEffective.toString()
- val fee = t.amountEffective - t.amountRaw
- bindOrderAndFee(
- ui.orderSummaryView,
- ui.orderAmountView,
- ui.orderIdView,
- ui.feeView,
- t.info,
- t.amountRaw,
- fee
- )
- ui.deleteButton.setOnClickListener {
- onDeleteButtonClicked(t)
+ ): View = ComposeView(requireContext()).apply {
+ setContent {
+ TalerSurface {
+ val t = transactionManager.selectedTransaction.observeAsState().value
+ if (t is TransactionPayment) TransactionPaymentComposable(t, devMode,
+ balanceManager.getSpecForCurrency(t.amountRaw.currency),
+ onFulfill = { url ->
+ launchInAppBrowser(requireContext(), url)
+ },
+ onTransition = {
+ onTransitionButtonClicked(t, it)
+ }
+ )
+ }
}
}
-
}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt
index b7c347f..27809a7 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt
@@ -25,9 +25,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -37,6 +38,7 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.taler.common.Amount
+import net.taler.common.CurrencySpecification
import net.taler.common.toAbsoluteTime
import net.taler.wallet.R
import net.taler.wallet.compose.TalerSurface
@@ -54,9 +56,11 @@ class TransactionPeerFragment : TransactionDetailFragment() {
): View = ComposeView(requireContext()).apply {
setContent {
TalerSurface {
- val t = transaction ?: error("No transaction")
- TransactionPeerComposable(t) {
- onDeleteButtonClicked(t)
+ val t = transactionManager.selectedTransaction.observeAsState(null).value
+ if (t != null) TransactionPeerComposable(t, devMode,
+ balanceManager.getSpecForCurrency(t.amountRaw.currency),
+ ) {
+ onTransitionButtonClicked(t, it)
}
}
}
@@ -64,7 +68,12 @@ class TransactionPeerFragment : TransactionDetailFragment() {
}
@Composable
-fun TransactionPeerComposable(t: Transaction, onDelete: () -> Unit) {
+fun TransactionPeerComposable(
+ t: Transaction,
+ devMode: Boolean,
+ spec: CurrencySpecification?,
+ onTransition: (t: TransactionAction) -> Unit,
+) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
@@ -76,16 +85,19 @@ fun TransactionPeerComposable(t: Transaction, onDelete: () -> Unit) {
Text(
modifier = Modifier.padding(16.dp),
text = t.timestamp.ms.toAbsoluteTime(context).toString(),
- style = MaterialTheme.typography.body1,
+ style = MaterialTheme.typography.bodyLarge,
)
when (t) {
- is TransactionPeerPullCredit -> TransactionPeerPullCreditComposable(t)
- is TransactionPeerPushCredit -> TransactionPeerPushCreditComposable(t)
- is TransactionPeerPullDebit -> TransactionPeerPullDebitComposable(t)
- is TransactionPeerPushDebit -> TransactionPeerPushDebitComposable(t)
+ is TransactionPeerPullCredit -> TransactionPeerPullCreditComposable(t, spec)
+ is TransactionPeerPushCredit -> TransactionPeerPushCreditComposable(t, spec)
+ is TransactionPeerPullDebit -> TransactionPeerPullDebitComposable(t, spec)
+ is TransactionPeerPushDebit -> TransactionPeerPushDebitComposable(t, spec)
else -> error("unexpected transaction: ${t::class.simpleName}")
}
- DeleteTransactionComposable(onDelete)
+ TransitionsComposable(t, devMode, onTransition)
+ if (devMode && t.error != null) {
+ ErrorTransactionButton(error = t.error!!)
+ }
}
}
@@ -94,15 +106,15 @@ fun TransactionAmountComposable(label: String, amount: Amount, amountType: Amoun
Text(
modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
text = label,
- style = MaterialTheme.typography.body2,
+ style = MaterialTheme.typography.bodyMedium,
)
Text(
modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp),
- text = if (amountType == AmountType.Negative) "-$amount" else amount.toString(),
+ text = amount.toString(negative = amountType == AmountType.Negative),
fontSize = 24.sp,
color = when (amountType) {
AmountType.Positive -> colorResource(R.color.green)
- AmountType.Negative -> colorResource(R.color.red)
+ AmountType.Negative -> MaterialTheme.colorScheme.error
AmountType.Neutral -> Color.Unspecified
},
)
@@ -113,7 +125,7 @@ fun TransactionInfoComposable(label: String, info: String) {
Text(
modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
text = label,
- style = MaterialTheme.typography.body2,
+ style = MaterialTheme.typography.bodyMedium,
)
Text(
modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp),
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt
index d44db43..e55d887 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt
@@ -19,41 +19,102 @@ package net.taler.wallet.transactions
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
-import android.view.View.GONE
import android.view.ViewGroup
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.common.CurrencySpecification
+import net.taler.common.Timestamp
import net.taler.common.toAbsoluteTime
import net.taler.wallet.R
-import net.taler.wallet.cleanExchange
-import net.taler.wallet.databinding.FragmentTransactionWithdrawalBinding
+import net.taler.wallet.backend.TalerErrorCode
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
+import net.taler.wallet.transactions.TransactionMajorState.Pending
class TransactionRefreshFragment : TransactionDetailFragment() {
- private lateinit var ui: FragmentTransactionWithdrawalBinding
-
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
- ): View {
- ui = FragmentTransactionWithdrawalBinding.inflate(inflater, container, false)
- return ui.root
+ ): View = ComposeView(requireContext()).apply {
+ setContent {
+ TalerSurface {
+ val t = transactionManager.selectedTransaction.observeAsState().value
+ if (t is TransactionRefresh) TransactionRefreshComposable(t, devMode,
+ balanceManager.getSpecForCurrency(t.amountRaw.currency),
+ ) {
+ onTransitionButtonClicked(t, it)
+ }
+ }
+ }
}
+}
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- val t = transaction as TransactionRefresh
- ui.timeView.text = t.timestamp.ms.toAbsoluteTime(requireContext())
-
- ui.effectiveAmountLabel.visibility = GONE
- ui.effectiveAmountView.visibility = GONE
- ui.confirmWithdrawalButton.visibility = GONE
- ui.chosenAmountLabel.visibility = GONE
- ui.chosenAmountView.visibility = GONE
- val fee = t.amountEffective
- ui.feeView.text = getString(R.string.amount_negative, fee.toString())
- ui.exchangeView.text = cleanExchange(t.exchangeBaseUrl)
- ui.deleteButton.setOnClickListener {
- onDeleteButtonClicked(t)
+@Composable
+private fun TransactionRefreshComposable(
+ t: TransactionRefresh,
+ devMode: Boolean,
+ spec: CurrencySpecification?,
+ onTransition: (t: TransactionAction) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ val context = LocalContext.current
+ Text(
+ modifier = Modifier.padding(16.dp),
+ text = t.timestamp.ms.toAbsoluteTime(context).toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_fee),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+ TransitionsComposable(t, devMode, onTransition)
+ if (devMode && t.error != null) {
+ ErrorTransactionButton(error = t.error)
}
}
+}
+@Preview
+@Composable
+private fun TransactionRefreshComposablePreview() {
+ val t = TransactionRefresh(
+ transactionId = "transactionId",
+ timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
+ txState = TransactionState(Pending),
+ txActions = listOf(Retry, Suspend, Abort),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.1337"),
+ error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED),
+ )
+ Surface {
+ TransactionRefreshComposable(t, true, null) {}
+ }
}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt
index 5e6eef4..7992565 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt
@@ -20,45 +20,27 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.core.content.ContextCompat.getColor
-import net.taler.common.toAbsoluteTime
-import net.taler.wallet.R
-import net.taler.wallet.databinding.FragmentTransactionPaymentBinding
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.platform.ComposeView
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.refund.TransactionRefundComposable
class TransactionRefundFragment : TransactionDetailFragment() {
- private lateinit var ui: FragmentTransactionPaymentBinding
-
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
- ): View {
- ui = FragmentTransactionPaymentBinding.inflate(inflater, container, false)
- return ui.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- val t = transaction as TransactionRefund
- ui.timeView.text = t.timestamp.ms.toAbsoluteTime(requireContext())
-
- ui.amountPaidWithFeesLabel.text = getString(R.string.transaction_refund)
- ui.amountPaidWithFeesView.setTextColor(getColor(requireContext(), R.color.green))
- ui.amountPaidWithFeesView.text =
- getString(R.string.amount_positive, t.amountEffective.toString())
- val fee = t.amountRaw - t.amountEffective
- bindOrderAndFee(
- ui.orderSummaryView,
- ui.orderAmountView,
- ui.orderIdView,
- ui.feeView,
- t.info,
- t.amountRaw,
- fee
- )
- ui.deleteButton.setOnClickListener {
- onDeleteButtonClicked(t)
+ ): View = ComposeView(requireContext()).apply {
+ setContent {
+ TalerSurface {
+ val t = transactionManager.selectedTransaction.observeAsState().value
+ if (t is TransactionRefund) TransactionRefundComposable(t, devMode,
+ balanceManager.getSpecForCurrency(t.amountRaw.currency)
+ ) {
+ onTransitionButtonClicked(t, it)
+ }
+ }
}
}
-
}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt
new file mode 100644
index 0000000..f89be83
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt
@@ -0,0 +1,99 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.transactions
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class TransactionState(
+ val major: TransactionMajorState,
+ val minor: TransactionMinorState? = null,
+) {
+ override fun equals(other: Any?): Boolean {
+ return if (other is TransactionState)
+ // if other.minor is null, then ignore minor in comparison
+ major == other.major && (other.minor == null || minor == other.minor)
+ else false
+ }
+
+ override fun hashCode(): Int {
+ var result = major.hashCode()
+ result = 31 * result + (minor?.hashCode() ?: 0)
+ return result
+ }
+}
+
+@Serializable
+enum class TransactionMajorState {
+ @SerialName("none")
+ None,
+
+ @SerialName("pending")
+ Pending,
+
+ @SerialName("done")
+ Done,
+
+ @SerialName("aborting")
+ Aborting,
+
+ @SerialName("aborted")
+ Aborted,
+
+ @SerialName("suspended")
+ Suspended,
+
+ @SerialName("dialog")
+ Dialog,
+
+ @SerialName("suspended-aborting")
+ SuspendedAborting,
+
+ @SerialName("failed")
+ Failed,
+
+ @SerialName("deleted")
+ Deleted,
+
+ @SerialName("expired")
+ Expired,
+
+ @SerialName("unknown")
+ Unknown;
+}
+
+@Serializable
+enum class TransactionMinorState {
+ @SerialName("kyc")
+ KycRequired,
+
+ @SerialName("exchange")
+ Exchange,
+
+ @SerialName("create-purse")
+ CreatePurse,
+
+ @SerialName("ready")
+ Ready,
+
+ @SerialName("bank-confirm-transfer")
+ BankConfirmTransfer,
+
+ @SerialName("exchange-wait-reserve")
+ ExchangeWaitReserve,
+}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionTipFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionTipFragment.kt
deleted file mode 100644
index 3f370c7..0000000
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionTipFragment.kt
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2022 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.transactions
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment.Companion.CenterHorizontally
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import net.taler.common.Amount
-import net.taler.common.Timestamp
-import net.taler.common.toAbsoluteTime
-import net.taler.wallet.R
-import net.taler.wallet.compose.TalerSurface
-
-class TransactionTipFragment : TransactionDetailFragment() {
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View = ComposeView(requireContext()).apply {
- setContent {
- TalerSurface {
- val t = transaction as? TransactionTip ?: error("No or wrong transaction")
- TransactionTipComposable(t) {
- onDeleteButtonClicked(t)
- }
- }
- }
- }
-}
-
-@Composable
-fun TransactionTipComposable(t: TransactionTip, onDelete: () -> Unit) {
- val scrollState = rememberScrollState()
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(scrollState),
- horizontalAlignment = CenterHorizontally,
- ) {
- val context = LocalContext.current
- Text(
- modifier = Modifier.padding(16.dp),
- text = t.timestamp.ms.toAbsoluteTime(context).toString(),
- style = MaterialTheme.typography.body1,
- )
-
- TransactionAmountComposable(
- label = stringResource(id = R.string.send_peer_payment_amount_received),
- amount = t.amountEffective,
- amountType = AmountType.Positive,
- )
- TransactionAmountComposable(
- label = stringResource(id = R.string.send_peer_payment_amount_sent),
- amount = t.amountRaw,
- amountType = AmountType.Neutral,
- )
- val fee = t.amountRaw - t.amountEffective
- if (!fee.isZero()) {
- TransactionAmountComposable(
- label = stringResource(id = R.string.withdraw_fees),
- amount = fee,
- amountType = AmountType.Negative,
- )
- }
- TransactionInfoComposable(
- label = stringResource(id = R.string.tip_merchant_url),
- info = t.merchantBaseUrl,
- )
- DeleteTransactionComposable(onDelete)
- }
-}
-
-@Preview
-@Composable
-fun TransactionTipPreview() {
- val t = TransactionTip(
- transactionId = "transactionId",
- timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
- pending = true,
- merchantBaseUrl = "https://merchant.example.org/",
- amountRaw = Amount.fromDouble("TESTKUDOS", 42.23),
- amountEffective = Amount.fromDouble("TESTKUDOS", 42.1337),
- )
- Surface {
- TransactionTipComposable(t) {}
- }
-}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt
index ffc9005..27e59bb 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt
@@ -19,104 +19,78 @@ package net.taler.wallet.transactions
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
-import android.view.View.GONE
-import android.view.View.VISIBLE
import android.view.ViewGroup
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
-import net.taler.common.toAbsoluteTime
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
-import net.taler.wallet.cleanExchange
-import net.taler.wallet.databinding.FragmentTransactionWithdrawalBinding
-import net.taler.wallet.handleKyc
+import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.launchInAppBrowser
import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer
import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi
+import net.taler.wallet.withdraw.TransactionWithdrawalComposable
import net.taler.wallet.withdraw.createManualTransferRequired
-class TransactionWithdrawalFragment : TransactionDetailFragment() {
+class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListener {
private val model: MainViewModel by activityViewModels()
private val withdrawManager by lazy { model.withdrawManager }
- private lateinit var ui: FragmentTransactionWithdrawalBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
- ): View {
- ui = FragmentTransactionWithdrawalBinding.inflate(inflater, container, false)
- return ui.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- val t = transaction as TransactionWithdrawal
- ui.timeView.text = t.timestamp.ms.toAbsoluteTime(requireContext())
-
- ui.effectiveAmountLabel.text = getString(R.string.withdraw_total)
- ui.effectiveAmountView.text = t.amountEffective.toString()
- setupConfirmWithdrawalButton(t)
- setupActionButton(t)
- ui.chosenAmountLabel.text = getString(R.string.amount_chosen)
- ui.chosenAmountView.text =
- getString(R.string.amount_positive, t.amountRaw.toString())
- val fee = t.amountRaw - t.amountEffective
- ui.feeView.text = getString(R.string.amount_negative, fee.toString())
- ui.exchangeView.text = cleanExchange(t.exchangeBaseUrl)
- if (t.pending) {
- ui.deleteButton.setIconResource(R.drawable.ic_cancel)
- ui.deleteButton.setText(R.string.cancel)
- }
- ui.deleteButton.setOnClickListener {
- onDeleteButtonClicked(t)
+ ): View = ComposeView(requireContext()).apply {
+ setContent {
+ TalerSurface {
+ val t = transactionManager.selectedTransaction.observeAsState().value
+ if (t is TransactionWithdrawal) TransactionWithdrawalComposable(
+ t = t,
+ devMode = devMode,
+ spec = balanceManager.getSpecForCurrency(t.amountRaw.currency),
+ actionListener = this@TransactionWithdrawalFragment,
+ ) {
+ onTransitionButtonClicked(t, it)
+ }
+ }
}
}
- override val deleteDialogTitle: Int
- get() = if (transaction?.pending == true) R.string.cancel else super.deleteDialogTitle
- override val deleteDialogMessage: Int
- get() = if (transaction?.pending == true) R.string.transactions_cancel_dialog_message
- else super.deleteDialogMessage
- override val deleteDialogButton: Int
- get() = if (transaction?.pending == true) R.string.ok else super.deleteDialogButton
-
- private fun setupConfirmWithdrawalButton(t: TransactionWithdrawal) {
- if (t.pending && !t.confirmed) {
- if (t.withdrawalDetails is TalerBankIntegrationApi &&
- t.withdrawalDetails.bankConfirmationUrl != null
- ) {
- ui.confirmWithdrawalButton.setOnClickListener {
- launchInAppBrowser(requireContext(), t.withdrawalDetails.bankConfirmationUrl)
- }
- } else if (t.withdrawalDetails is ManualTransfer) {
- ui.confirmWithdrawalButton.setText(R.string.withdraw_manual_ready_details_intro)
- ui.confirmWithdrawalButton.setOnClickListener {
- val status = createManualTransferRequired(
- amount = t.amountRaw,
- exchangeBaseUrl = t.exchangeBaseUrl,
- // TODO what if there's more than one or no URI?
- uriStr = t.withdrawalDetails.exchangePaytoUris[0],
- transactionId = t.transactionId,
- )
- withdrawManager.viewManualWithdrawal(status)
- findNavController().navigate(
- R.id.action_nav_transactions_detail_withdrawal_to_nav_exchange_manual_withdrawal_success
- )
+ override fun onActionButtonClicked(tx: Transaction, type: ActionListener.Type) {
+ when (type) {
+ ActionListener.Type.COMPLETE_KYC -> {
+ if (tx !is TransactionWithdrawal) return
+ tx.kycUrl?.let {
+ launchInAppBrowser(requireContext(), it)
}
- } else ui.confirmWithdrawalButton.visibility = GONE
- } else ui.confirmWithdrawalButton.visibility = GONE
- }
+ }
- private fun setupActionButton(t: TransactionWithdrawal) {
- ui.actionButton.visibility = t.handleKyc({ GONE }) { error ->
- ui.actionButton.setText(R.string.transaction_action_kyc)
- error.kycUrl?.let { kycUrl ->
- ui.actionButton.setOnClickListener {
- launchInAppBrowser(requireContext(), kycUrl)
+ ActionListener.Type.CONFIRM_WITH_BANK -> {
+ if (tx !is TransactionWithdrawal) return
+ if (tx.withdrawalDetails !is TalerBankIntegrationApi) return
+ tx.withdrawalDetails.bankConfirmationUrl?.let { url ->
+ launchInAppBrowser(requireContext(), url)
}
}
- VISIBLE
+
+ ActionListener.Type.CONFIRM_MANUAL -> {
+ if (tx !is TransactionWithdrawal) return
+ if (tx.withdrawalDetails !is ManualTransfer) return
+ if (tx.withdrawalDetails.exchangeCreditAccountDetails.isNullOrEmpty()) return
+ val status = createManualTransferRequired(
+ transactionId = tx.transactionId,
+ exchangeBaseUrl = tx.exchangeBaseUrl,
+ amountRaw = tx.amountRaw,
+ amountEffective = tx.amountEffective,
+ withdrawalAccountList = tx.withdrawalDetails.exchangeCreditAccountDetails,
+ )
+ withdrawManager.viewManualWithdrawal(status)
+ findNavController().navigate(
+ R.id.action_nav_transactions_detail_withdrawal_to_nav_exchange_manual_withdrawal_success,
+ )
+ }
}
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
index 740aab6..2bd204c 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
@@ -17,30 +17,93 @@
package net.taler.wallet.transactions
import android.content.Context
+import android.net.Uri
+import android.util.Log
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.StringRes
+import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
import kotlinx.serialization.Transient
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.builtins.MapSerializer
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonElement
import net.taler.common.Amount
import net.taler.common.ContractMerchant
import net.taler.common.ContractProduct
import net.taler.common.Timestamp
import net.taler.wallet.R
+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.wallet.cleanExchange
+import net.taler.wallet.refund.RefundPaymentInfo
+import net.taler.wallet.transactions.TransactionMajorState.None
+import net.taler.wallet.transactions.TransactionMajorState.Pending
import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer
import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi
+import java.util.UUID
@Serializable
-data class Transactions(val transactions: List<Transaction>)
+data class Transactions(
+ @Serializable(with = TransactionListSerializer::class)
+ val transactions: List<Transaction>,
+)
+
+class TransactionListSerializer : KSerializer<List<Transaction>> {
+ private val serializer = ListSerializer(TransactionSerializer())
+ override val descriptor: SerialDescriptor = serializer.descriptor
+
+ override fun deserialize(decoder: Decoder): List<Transaction> {
+ return decoder.decodeSerializableValue(serializer)
+ }
+
+ override fun serialize(encoder: Encoder, value: List<Transaction>) {
+ throw NotImplementedError()
+ }
+}
+
+class TransactionSerializer : KSerializer<Transaction> {
+
+ private val serializer = Transaction.serializer()
+ override val descriptor: SerialDescriptor = serializer.descriptor
+ private val jsonSerializer = MapSerializer(String.serializer(), JsonElement.serializer())
+
+ override fun deserialize(decoder: Decoder): Transaction {
+ return try {
+ decoder.decodeSerializableValue(serializer)
+ } catch (e: SerializationException) {
+ Log.e(TAG, "Error deserializing transaction.", e)
+ DummyTransaction(
+ transactionId = UUID.randomUUID().toString(),
+ timestamp = Timestamp.now(),
+ error = TalerErrorInfo(
+ code = TalerErrorCode.UNKNOWN,
+ message = e.message,
+ extra = decoder.decodeSerializableValue(jsonSerializer)
+ ),
+ )
+ }
+ }
+
+ override fun serialize(encoder: Encoder, value: Transaction) {
+ throw NotImplementedError()
+ }
+}
@Serializable
sealed class Transaction {
abstract val transactionId: String
abstract val timestamp: Timestamp
- abstract val pending: Boolean
+ abstract val txState: TransactionState
+ abstract val txActions: List<TransactionAction>
abstract val error: TalerErrorInfo?
abstract val amountRaw: Amount
abstract val amountEffective: Amount
@@ -59,6 +122,28 @@ sealed class Transaction {
abstract val generalTitleRes: Int
}
+@Serializable
+enum class TransactionAction {
+ // Common States
+ @SerialName("delete")
+ Delete,
+
+ @SerialName("suspend")
+ Suspend,
+
+ @SerialName("resume")
+ Resume,
+
+ @SerialName("abort")
+ Abort,
+
+ @SerialName("fail")
+ Fail,
+
+ @SerialName("retry")
+ Retry,
+}
+
sealed class AmountType {
object Positive : AmountType()
object Negative : AmountType()
@@ -70,7 +155,9 @@ sealed class AmountType {
class TransactionWithdrawal(
override val transactionId: String,
override val timestamp: Timestamp,
- override val pending: Boolean,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
+ val kycUrl: String? = null,
val exchangeBaseUrl: String,
val withdrawalDetails: WithdrawalDetails,
override val error: TalerErrorInfo? = null,
@@ -86,7 +173,7 @@ class TransactionWithdrawal(
override fun getTitle(context: Context) = cleanExchange(exchangeBaseUrl)
override val generalTitleRes = R.string.withdraw_title
val confirmed: Boolean
- get() = !pending && (
+ get() = txState.major != Pending && (
(withdrawalDetails is TalerBankIntegrationApi && withdrawalDetails.confirmed) ||
withdrawalDetails is ManualTransfer
)
@@ -97,12 +184,7 @@ sealed class WithdrawalDetails {
@Serializable
@SerialName("manual-transfer")
class ManualTransfer(
- /**
- * Payto URIs that the exchange supports.
- *
- * Already contains the amount and message.
- */
- val exchangePaytoUris: List<String>,
+ val exchangeCreditAccountDetails: List<WithdrawalExchangeAccountDetails>? = null,
) : WithdrawalDetails()
@Serializable
@@ -124,16 +206,108 @@ sealed class WithdrawalDetails {
}
@Serializable
+data class WithdrawalExchangeAccountDetails (
+ /**
+ * Payto URI to credit the exchange.
+ *
+ * Depending on whether the (manual!) withdrawal is accepted or just
+ * being checked, this already includes the subject with the
+ * reserve public key.
+ */
+ val paytoUri: String,
+
+ /**
+ * Status that indicates whether the account can be used
+ * by the user to send funds for a withdrawal.
+ *
+ * ok: account should be shown to the user
+ * error: account should not be shown to the user, UIs might render the error (in conversionError),
+ * especially in dev mode.
+ */
+ val status: Status,
+
+ /**
+ * Transfer amount. Might be in a different currency than the requested
+ * amount for withdrawal.
+ *
+ * Redundant with the amount in paytoUri, just included to avoid parsing.
+ */
+ val transferAmount: Amount? = null,
+
+ /**
+ * Currency specification for the external currency.
+ *
+ * Only included if this account requires a currency conversion.
+ */
+ val currencySpecification: CurrencySpecification? = null,
+
+ /**
+ * Further restrictions for sending money to the
+ * exchange.
+ */
+ val creditRestrictions: List<AccountRestriction>? = null,
+
+ /**
+ * Label given to the account or the account's bank by the exchange.
+ */
+ val bankLabel: String? = null,
+
+ val priority: Int? = null,
+) {
+ @Serializable
+ enum class Status {
+ @SerialName("ok")
+ Ok,
+
+ @SerialName("error")
+ Error;
+ }
+}
+
+@Serializable
+sealed class AccountRestriction {
+ @Serializable
+ @SerialName("deny")
+ data object DenyAllAccount: AccountRestriction()
+
+ @Serializable
+ @SerialName("regex")
+ data class RegexAccount(
+ // Regular expression that the payto://-URI of the
+ // partner account must follow. The regular expression
+ // should follow posix-egrep, but without support for character
+ // classes, GNU extensions, back-references or intervals. See
+ // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html
+ // for a description of the posix-egrep syntax. Applications
+ // may support regexes with additional features, but exchanges
+ // must not use such regexes.
+ @SerialName("payto_regex")
+ val paytoRegex: String,
+
+ // Hint for a human to understand the restriction
+ // (that is hopefully easier to comprehend than the regex itself).
+ @SerialName("human_hint")
+ val humanHint: String,
+
+ // Map from IETF BCP 47 language tags to localized
+ // human hints.
+ @SerialName("human_hint_i18n")
+ val humanHintI18n: Map<String, String>? = null,
+ ): AccountRestriction()
+}
+
+@Serializable
@SerialName("payment")
class TransactionPayment(
override val transactionId: String,
override val timestamp: Timestamp,
- override val pending: Boolean,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
val info: TransactionInfo,
- val status: PaymentStatus,
override val error: TalerErrorInfo? = null,
override val amountRaw: Amount,
override val amountEffective: Amount,
+ val posConfirmation: String? = null,
) : Transaction() {
override val icon = R.drawable.ic_cash_usd_outline
override val detailPageNav = R.id.action_nav_transactions_detail_payment
@@ -151,7 +325,7 @@ class TransactionInfo(
val summary: String,
@SerialName("summary_i18n")
val summaryI18n: Map<String, String>? = null,
- val products: List<ContractProduct>,
+ val products: List<ContractProduct> = emptyList(),
val fulfillmentUrl: String? = null,
/**
* Message shown to the user after the payment is complete.
@@ -164,32 +338,14 @@ class TransactionInfo(
)
@Serializable
-enum class PaymentStatus {
- @SerialName("aborted")
- Aborted,
-
- @SerialName("failed")
- Failed,
-
- @SerialName("paid")
- Paid,
-
- @SerialName("accepted")
- Accepted
-}
-
-@Serializable
@SerialName("refund")
class TransactionRefund(
override val transactionId: String,
override val timestamp: Timestamp,
- override val pending: Boolean,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
val refundedTransactionId: String,
- val info: TransactionInfo,
- /**
- * Part of the refund that couldn't be applied because the refund permissions were expired
- */
- val amountInvalid: Amount? = null,
+ val paymentInfo: RefundPaymentInfo? = null,
override val error: TalerErrorInfo? = null,
override val amountRaw: Amount,
override val amountEffective: Amount,
@@ -199,43 +355,18 @@ class TransactionRefund(
@Transient
override val amountType = AmountType.Positive
- override fun getTitle(context: Context): String {
- return context.getString(R.string.transaction_refund_from, info.merchant.name)
- }
+ override fun getTitle(context: Context) = paymentInfo?.merchant?.name ?: context.getString(R.string.transaction_refund)
override val generalTitleRes = R.string.refund_title
}
@Serializable
-@SerialName("tip")
-class TransactionTip(
- override val transactionId: String,
- override val timestamp: Timestamp,
- override val pending: Boolean,
- val merchantBaseUrl: String,
- override val error: TalerErrorInfo? = null,
- override val amountRaw: Amount,
- override val amountEffective: Amount,
-) : Transaction() {
- override val icon = R.drawable.transaction_tip_accepted
- override val detailPageNav = R.id.action_nav_transactions_detail_tip
-
- @Transient
- override val amountType = AmountType.Positive
- override fun getTitle(context: Context): String {
- return context.getString(R.string.transaction_tip_from, merchantBaseUrl)
- }
-
- override val generalTitleRes = R.string.tip_title
-}
-
-@Serializable
@SerialName("refresh")
class TransactionRefresh(
override val transactionId: String,
override val timestamp: Timestamp,
- override val pending: Boolean,
- val exchangeBaseUrl: String,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
override val error: TalerErrorInfo? = null,
override val amountRaw: Amount,
override val amountEffective: Amount,
@@ -257,7 +388,8 @@ class TransactionRefresh(
class TransactionDeposit(
override val transactionId: String,
override val timestamp: Timestamp,
- override val pending: Boolean,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
override val error: TalerErrorInfo? = null,
override val amountRaw: Amount,
override val amountEffective: Amount,
@@ -270,7 +402,10 @@ class TransactionDeposit(
@Transient
override val amountType = AmountType.Negative
override fun getTitle(context: Context): String {
- return context.getString(R.string.transaction_deposit)
+ val uri = Uri.parse(targetPaytoUri)
+ return uri.getQueryParameter("receiver-name")?.let { receiverName ->
+ context.getString(R.string.transaction_deposit_to, receiverName)
+ } ?: context.getString(R.string.transaction_deposit)
}
override val generalTitleRes = R.string.transaction_deposit
@@ -290,7 +425,8 @@ data class PeerInfoShort(
class TransactionPeerPullDebit(
override val transactionId: String,
override val timestamp: Timestamp,
- override val pending: Boolean,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
val exchangeBaseUrl: String,
override val error: TalerErrorInfo? = null,
override val amountRaw: Amount,
@@ -317,7 +453,8 @@ class TransactionPeerPullDebit(
class TransactionPeerPullCredit(
override val transactionId: String,
override val timestamp: Timestamp,
- override val pending: Boolean,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
val exchangeBaseUrl: String,
override val error: TalerErrorInfo? = null,
override val amountRaw: Amount,
@@ -345,13 +482,14 @@ class TransactionPeerPullCredit(
class TransactionPeerPushDebit(
override val transactionId: String,
override val timestamp: Timestamp,
- override val pending: Boolean,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
val exchangeBaseUrl: String,
override val error: TalerErrorInfo? = null,
override val amountRaw: Amount,
override val amountEffective: Amount,
val info: PeerInfoShort,
- val talerUri: String,
+ val talerUri: String? = null,
// val completed: Boolean, definitely
) : Transaction() {
override val icon = R.drawable.ic_cash_usd_outline
@@ -374,7 +512,8 @@ class TransactionPeerPushDebit(
class TransactionPeerPushCredit(
override val transactionId: String,
override val timestamp: Timestamp,
- override val pending: Boolean,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
val exchangeBaseUrl: String,
override val error: TalerErrorInfo? = null,
override val amountRaw: Amount,
@@ -392,3 +531,65 @@ class TransactionPeerPushCredit(
override val generalTitleRes = R.string.transaction_peer_push_credit
}
+
+/**
+ * A transaction to indicate financial loss due to denominations
+ * that became unusable for deposits.
+ */
+@Serializable
+@SerialName("denom-loss")
+class TransactionDenomLoss(
+ override val transactionId: String,
+ override val timestamp: Timestamp,
+ override val txState: TransactionState,
+ override val txActions: List<TransactionAction>,
+ override val error: TalerErrorInfo? = null,
+ override val amountRaw: Amount,
+ override val amountEffective: Amount,
+ val lossEventType: LossEventType,
+): Transaction() {
+ override val icon: Int = R.drawable.transaction_loss
+ override val detailPageNav = R.id.nav_transactions_detail_loss
+
+ @Transient
+ override val amountType: AmountType = AmountType.Negative
+
+ override fun getTitle(context: Context): String {
+ return context.getString(R.string.transaction_denom_loss)
+ }
+
+ override val generalTitleRes: Int = R.string.transaction_denom_loss
+}
+
+@Serializable
+enum class LossEventType {
+ @SerialName("denom-expired")
+ DenomExpired,
+
+ @SerialName("denom-vanished")
+ DenomVanished,
+
+ @SerialName("denom-unoffered")
+ DenomUnoffered
+}
+
+/**
+ * This represents a transaction that we can not parse for some reason.
+ */
+class DummyTransaction(
+ override val transactionId: String,
+ override val timestamp: Timestamp,
+ override val error: TalerErrorInfo,
+) : Transaction() {
+ override val txState: TransactionState = TransactionState(None)
+ override val txActions: List<TransactionAction> = emptyList()
+ override val amountRaw: Amount = Amount.zero("TESTKUDOS")
+ override val amountEffective: Amount = Amount.zero("TESTKUDOS")
+ override val icon: Int = R.drawable.ic_bug_report
+ override val detailPageNav: Int = R.id.nav_transactions_detail_dummy
+ override val amountType: AmountType = AmountType.Neutral
+ override val generalTitleRes: Int = R.string.transaction_dummy_title
+ override fun getTitle(context: Context): String {
+ return context.getString(R.string.transaction_dummy_title)
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt
index 09adb3e..d2d0c9c 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt
@@ -17,6 +17,7 @@
package net.taler.wallet.transactions
import android.os.Bundle
+import android.util.Log
import android.view.ActionMode
import android.view.LayoutInflater
import android.view.Menu
@@ -25,7 +26,7 @@ import android.view.MenuItem
import android.view.View
import android.view.View.INVISIBLE
import android.view.ViewGroup
-import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.SearchView.OnQueryTextListener
import androidx.fragment.app.Fragment
@@ -36,28 +37,32 @@ import androidx.recyclerview.selection.SelectionTracker
import androidx.recyclerview.selection.StorageStrategy
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
-import net.taler.common.Amount
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import net.taler.common.fadeIn
import net.taler.common.fadeOut
+import net.taler.common.showError
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
+import net.taler.wallet.TAG
+import net.taler.wallet.balances.BalanceState.Success
+import net.taler.wallet.balances.ScopeInfo
+import net.taler.wallet.cleanExchange
import net.taler.wallet.databinding.FragmentTransactionsBinding
-import net.taler.wallet.handleKyc
-import net.taler.wallet.launchInAppBrowser
+import net.taler.wallet.showError
interface OnTransactionClickListener {
fun onTransactionClicked(transaction: Transaction)
- fun onActionButtonClicked(transaction: Transaction)
}
class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode.Callback {
private val model: MainViewModel by activityViewModels()
private val transactionManager by lazy { model.transactionManager }
+ private val balanceManager by lazy { model.balanceManager }
private lateinit var ui: FragmentTransactionsBinding
private val transactionAdapter by lazy { TransactionAdapter(this) }
- private val currency by lazy { transactionManager.selectedCurrency!! }
+ private val scopeInfo by lazy { transactionManager.selectedScope!! }
private var tracker: SelectionTracker<String>? = null
private var actionMode: ActionMode? = null
@@ -106,11 +111,15 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode.
}
})
- model.balances.observe(viewLifecycleOwner) { balances ->
+ balanceManager.state.observe(viewLifecycleOwner) { state ->
+ if (state !is Success) return@observe
+ val balances = state.balances
// hide extra fab when in single currency mode (uses MainFragment's FAB)
if (balances.size == 1) ui.mainFab.visibility = INVISIBLE
- balances.find { it.currency == currency }?.available?.let { amount: Amount ->
- ui.amount.text = amount.amountStr
+
+ balances.find { it.scopeInfo == scopeInfo }?.let { balance ->
+ ui.actionsBar.amount.text = balance.available.toString(showSymbol = false)
+ transactionAdapter.setCurrencySpec(balance.available.spec)
}
}
transactionManager.progress.observe(viewLifecycleOwner) { show ->
@@ -119,11 +128,11 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode.
transactionManager.transactions.observe(viewLifecycleOwner) { result ->
onTransactionsResult(result)
}
- ui.sendButton.setOnClickListener {
+ ui.actionsBar.sendButton.setOnClickListener {
findNavController().navigate(R.id.sendFunds)
}
- ui.receiveButton.setOnClickListener {
- findNavController().navigate(R.id.receiveFunds)
+ ui.actionsBar.receiveButton.setOnClickListener {
+ findNavController().navigate(R.id.action_global_receiveFunds)
}
ui.mainFab.setOnClickListener {
model.scanCode()
@@ -147,7 +156,9 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode.
override fun onStart() {
super.onStart()
- requireActivity().title = getString(R.string.transactions_detail_title_currency, currency)
+ requireActivity().title = getString(R.string.transactions_detail_title_currency, scopeInfo.currency)
+ (requireActivity() as AppCompatActivity).supportActionBar?.subtitle =
+ (scopeInfo as? ScopeInfo.Exchange)?.url?.let { cleanExchange(it) }
}
private fun setupSearch(item: MenuItem) {
@@ -175,25 +186,18 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode.
override fun onTransactionClicked(transaction: Transaction) {
if (actionMode != null) return // don't react on clicks while in action mode
if (transaction.detailPageNav != 0) {
- transactionManager.selectedTransaction = transaction
+ transactionManager.selectTransaction(transaction)
findNavController().navigate(transaction.detailPageNav)
}
}
- override fun onActionButtonClicked(transaction: Transaction) {
- transaction.handleKyc({ error("Unhandled Action Button Event") }) { error ->
- error.kycUrl?.let {
- launchInAppBrowser(requireContext(), it)
- }
- }
- }
-
private fun onTransactionsResult(result: TransactionsResult) = when (result) {
is TransactionsResult.Error -> {
ui.list.fadeOut()
- ui.emptyState.text = getString(R.string.transactions_error, result.msg)
+ ui.emptyState.text = getString(R.string.transactions_error, result.error.userFacingMsg)
ui.emptyState.fadeIn()
}
+
is TransactionsResult.Success -> {
if (result.transactions.isEmpty()) {
val isSearch = transactionManager.searchQuery.value != null
@@ -232,25 +236,41 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode.
when (item.itemId) {
R.id.transaction_delete -> {
tracker?.selection?.toList()?.let { transactionIds ->
- AlertDialog.Builder(requireContext(), R.style.DialogTheme)
+ MaterialAlertDialogBuilder(
+ requireContext(),
+ R.style.MaterialAlertDialog_Material3,
+ )
.setTitle(R.string.transactions_delete)
.setMessage(R.string.transactions_delete_selected_dialog_message)
.setNeutralButton(R.string.cancel) { dialog, _ ->
dialog.cancel()
}
.setNegativeButton(R.string.transactions_delete) { dialog, _ ->
- transactionManager.deleteTransactions(transactionIds)
+ transactionManager.deleteTransactions(transactionIds) {
+ Log.e(TAG, "Error deleteTransaction $it")
+ if (model.devMode.value == true) {
+ showError(it)
+ } else {
+ showError(it.userFacingMsg)
+ }
+ }
dialog.dismiss()
}
.show()
}
mode.finish()
}
+
R.id.transaction_select_all -> transactionAdapter.selectAll()
}
return true
}
+ override fun onStop() {
+ super.onStop()
+ (requireActivity() as AppCompatActivity).supportActionBar?.subtitle = null
+ }
+
override fun onDestroyActionMode(mode: ActionMode) {
tracker?.clearSelection()
actionMode = null
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransitionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransitionsComposable.kt
new file mode 100644
index 0000000..1d91107
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/transactions/TransitionsComposable.kt
@@ -0,0 +1,113 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.transactions
+
+import androidx.compose.foundation.layout.Arrangement.Center
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import net.taler.wallet.R
+import net.taler.wallet.transactions.TransactionAction.*
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun TransitionsComposable(
+ t: Transaction,
+ devMode: Boolean,
+ onTransition: (t: TransactionAction) -> Unit,
+) {
+ FlowRow(horizontalArrangement = Center) {
+ t.txActions.forEach {
+ if (it in arrayOf(Resume, Suspend, Retry)) {
+ if (devMode) TransitionComposable(it, onTransition)
+ } else {
+ TransitionComposable(it, onTransition)
+ }
+ }
+ }
+}
+
+@Composable
+fun TransitionComposable(t: TransactionAction, onClick: (t: TransactionAction) -> Unit) {
+ Button(
+ modifier = Modifier.padding(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = when (t) {
+ Delete -> MaterialTheme.colorScheme.error
+ Retry -> MaterialTheme.colorScheme.primary
+ Abort -> MaterialTheme.colorScheme.error
+ Fail -> MaterialTheme.colorScheme.error
+ Resume -> MaterialTheme.colorScheme.primary
+ Suspend -> MaterialTheme.colorScheme.primary
+ }
+ ),
+ onClick = { onClick(t) },
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ painter = when (t) {
+ Delete -> painterResource(id = R.drawable.ic_delete)
+ Retry -> painterResource(id = R.drawable.ic_retry)
+ Abort -> painterResource(id = R.drawable.ic_cancel)
+ Fail -> painterResource(id = R.drawable.ic_fail)
+ Resume -> painterResource(id = R.drawable.ic_resume)
+ Suspend -> painterResource(id = R.drawable.ic_suspend)
+ },
+ contentDescription = null,
+ tint = when (t) {
+ Delete -> MaterialTheme.colorScheme.onError
+ Retry -> MaterialTheme.colorScheme.onPrimary
+ Abort -> MaterialTheme.colorScheme.onError
+ Fail -> MaterialTheme.colorScheme.onError
+ Resume -> MaterialTheme.colorScheme.onPrimary
+ Suspend -> MaterialTheme.colorScheme.onPrimary
+ },
+ )
+ Text(
+ modifier = Modifier.padding(start = 8.dp),
+ text = when (t) {
+ Delete -> stringResource(R.string.transactions_delete)
+ Retry -> stringResource(R.string.transactions_retry)
+ Abort -> stringResource(R.string.transactions_abort)
+ Fail -> stringResource(R.string.transactions_fail)
+ Resume -> stringResource(R.string.transactions_resume)
+ Suspend -> stringResource(R.string.transactions_suspend)
+ },
+ color = when (t) {
+ Delete -> MaterialTheme.colorScheme.onError
+ Retry -> MaterialTheme.colorScheme.onPrimary
+ Abort -> MaterialTheme.colorScheme.onError
+ Fail -> MaterialTheme.colorScheme.onError
+ Resume -> MaterialTheme.colorScheme.onPrimary
+ Suspend -> MaterialTheme.colorScheme.onPrimary
+ },
+ )
+ }
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt
index dbf901a..9983409 100644
--- a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt
@@ -23,9 +23,11 @@ import android.view.ViewGroup
import android.widget.ArrayAdapter
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.Snackbar
import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
+import kotlinx.coroutines.launch
import net.taler.common.Amount
import net.taler.common.EventObserver
import net.taler.common.fadeIn
@@ -34,15 +36,21 @@ import net.taler.wallet.MainViewModel
import net.taler.wallet.R
import net.taler.wallet.cleanExchange
import net.taler.wallet.databinding.FragmentPromptWithdrawBinding
+import net.taler.wallet.exchanges.ExchangeItem
+import net.taler.wallet.exchanges.SelectExchangeDialogFragment
import net.taler.wallet.withdraw.WithdrawStatus.Loading
import net.taler.wallet.withdraw.WithdrawStatus.ReceivedDetails
import net.taler.wallet.withdraw.WithdrawStatus.TosReviewRequired
import net.taler.wallet.withdraw.WithdrawStatus.Withdrawing
+import net.taler.wallet.withdraw.WithdrawStatus.NeedsExchange
class PromptWithdrawFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
private val withdrawManager by lazy { model.withdrawManager }
+ private val transactionManager by lazy { model.transactionManager }
+
+ private val selectExchangeDialog = SelectExchangeDialogFragment()
private lateinit var ui: FragmentPromptWithdrawBinding
@@ -61,22 +69,20 @@ class PromptWithdrawFragment : Fragment() {
withdrawManager.withdrawStatus.observe(viewLifecycleOwner) {
showWithdrawStatus(it)
}
- withdrawManager.exchangeSelection.observe(viewLifecycleOwner, EventObserver {
- findNavController().navigate(R.id.action_promptWithdraw_to_selectExchangeFragment)
+
+ selectExchangeDialog.exchangeSelection.observe(viewLifecycleOwner, EventObserver {
+ onExchangeSelected(it)
})
}
private fun showWithdrawStatus(status: WithdrawStatus?): Any = when (status) {
null -> model.showProgressBar.value = false
is Loading -> model.showProgressBar.value = true
- is WithdrawStatus.NeedsExchange -> {
+ is NeedsExchange -> {
model.showProgressBar.value = false
- val exchangeSelection = status.exchangeSelection.getIfNotConsumed()
- if (exchangeSelection == null) { // already consumed
- findNavController().popBackStack()
- } else {
- withdrawManager.selectExchange(exchangeSelection)
- }
+ if (selectExchangeDialog.dialog?.isShowing != true) {
+ selectExchange()
+ } else {}
}
is TosReviewRequired -> onTosReviewRequired(status)
is ReceivedDetails -> onReceivedDetails(status)
@@ -88,9 +94,15 @@ class PromptWithdrawFragment : Fragment() {
is WithdrawStatus.Success -> {
model.showProgressBar.value = false
withdrawManager.withdrawStatus.value = null
- findNavController().navigate(R.id.action_promptWithdraw_to_nav_main)
- model.showTransactions(status.currency)
- Snackbar.make(requireView(), R.string.withdraw_initiated, LENGTH_LONG).show()
+ lifecycleScope.launch {
+ // now select new transaction based on currency and ID
+ if (transactionManager.selectTransaction(status.transactionId)) {
+ findNavController().navigate(R.id.action_promptWithdraw_to_nav_transactions_detail_withdrawal)
+ } else {
+ findNavController().navigate(R.id.action_promptWithdraw_to_nav_main)
+ }
+ Snackbar.make(requireView(), R.string.withdraw_initiated, LENGTH_LONG).show()
+ }
}
is WithdrawStatus.Error -> {
model.showProgressBar.value = false
@@ -103,7 +115,13 @@ class PromptWithdrawFragment : Fragment() {
if (s.showImmediately.getIfNotConsumed() == true) {
findNavController().navigate(R.id.action_promptWithdraw_to_reviewExchangeTOS)
} else {
- showContent(s.amountRaw, s.amountEffective, s.exchangeBaseUrl, s.talerWithdrawUri)
+ showContent(
+ amountRaw = s.amountRaw,
+ amountEffective = s.amountEffective,
+ exchange = s.exchangeBaseUrl,
+ uri = s.talerWithdrawUri,
+ exchanges = s.possibleExchanges,
+ )
ui.confirmWithdrawButton.apply {
text = getString(R.string.withdraw_button_tos)
setOnClickListener {
@@ -115,8 +133,14 @@ class PromptWithdrawFragment : Fragment() {
}
private fun onReceivedDetails(s: ReceivedDetails) {
- showContent(s.amountRaw, s.amountEffective, s.exchangeBaseUrl, s.talerWithdrawUri,
- s.ageRestrictionOptions)
+ showContent(
+ amountRaw = s.amountRaw,
+ amountEffective = s.amountEffective,
+ exchange = s.exchangeBaseUrl,
+ uri = s.talerWithdrawUri,
+ ageRestrictionOptions = s.ageRestrictionOptions,
+ exchanges = s.possibleExchanges,
+ )
ui.confirmWithdrawButton.apply {
text = getString(R.string.withdraw_button_confirm)
setOnClickListener {
@@ -137,6 +161,7 @@ class PromptWithdrawFragment : Fragment() {
amountEffective: Amount,
exchange: String,
uri: String?,
+ exchanges: List<ExchangeItem> = emptyList(),
ageRestrictionOptions: List<Int>? = null,
) {
model.showProgressBar.value = false
@@ -150,20 +175,22 @@ class PromptWithdrawFragment : Fragment() {
ui.chosenAmountView.text = amountRaw.toString()
ui.chosenAmountView.fadeIn()
- ui.feeLabel.fadeIn()
- ui.feeView.text =
- getString(R.string.amount_negative, (amountRaw - amountEffective).toString())
- ui.feeView.fadeIn()
+ if (amountRaw > amountEffective) {
+ val fee = amountRaw - amountEffective
+ ui.feeLabel.fadeIn()
+ ui.feeView.text = getString(R.string.amount_negative, fee.toString())
+ ui.feeView.fadeIn()
+ }
ui.exchangeIntroView.fadeIn()
ui.withdrawExchangeUrl.text = cleanExchange(exchange)
ui.withdrawExchangeUrl.fadeIn()
- if (uri != null) { // no Uri for manual withdrawals
+ // no Uri for manual withdrawals, no selection for single exchange
+ if (uri != null && exchanges.size > 1) {
ui.selectExchangeButton.fadeIn()
ui.selectExchangeButton.setOnClickListener {
- val exchangeSelection = ExchangeSelection(amountRaw, uri)
- withdrawManager.selectExchange(exchangeSelection)
+ selectExchange()
}
}
@@ -179,4 +206,44 @@ class PromptWithdrawFragment : Fragment() {
ui.withdrawCard.fadeIn()
}
+ private fun selectExchange() {
+ val exchanges = when (val status = withdrawManager.withdrawStatus.value) {
+ is ReceivedDetails -> status.possibleExchanges
+ is NeedsExchange -> status.possibleExchanges
+ is TosReviewRequired -> status.possibleExchanges
+ else -> return
+ }
+ selectExchangeDialog.setExchanges(exchanges)
+ selectExchangeDialog.show(parentFragmentManager, "SELECT_EXCHANGE")
+ }
+
+ private fun onExchangeSelected(exchange: ExchangeItem) {
+ val status = withdrawManager.withdrawStatus.value
+ val amount = when (status) {
+ is ReceivedDetails -> status.amountRaw
+ is NeedsExchange -> status.amount
+ is TosReviewRequired -> status.amountRaw
+ else -> return
+ }
+ val uri = when (status) {
+ is ReceivedDetails -> status.talerWithdrawUri
+ is NeedsExchange -> status.talerWithdrawUri
+ is TosReviewRequired -> status.talerWithdrawUri
+ else -> return
+ }
+ val exchanges = when (status) {
+ is ReceivedDetails -> status.possibleExchanges
+ is NeedsExchange -> status.possibleExchanges
+ is TosReviewRequired -> status.possibleExchanges
+ else -> return
+ }
+
+ withdrawManager.getWithdrawalDetails(
+ exchangeBaseUrl = exchange.exchangeBaseUrl,
+ amount = amount,
+ showTosImmediately = false,
+ uri = uri,
+ possibleExchanges = exchanges,
+ )
+ }
}
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt b/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt
new file mode 100644
index 0000000..9bfeda6
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt
@@ -0,0 +1,157 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.withdraw
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.common.Timestamp
+import net.taler.common.toAbsoluteTime
+import net.taler.wallet.R
+import net.taler.wallet.backend.TalerErrorCode
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.cleanExchange
+import net.taler.common.CurrencySpecification
+import net.taler.wallet.transactions.ActionButton
+import net.taler.wallet.transactions.ActionListener
+import net.taler.wallet.transactions.AmountType
+import net.taler.wallet.transactions.ErrorTransactionButton
+import net.taler.wallet.transactions.Transaction
+import net.taler.wallet.transactions.TransactionAction
+import net.taler.wallet.transactions.TransactionAction.Abort
+import net.taler.wallet.transactions.TransactionAction.Retry
+import net.taler.wallet.transactions.TransactionAction.Suspend
+import net.taler.wallet.transactions.TransactionAmountComposable
+import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionMajorState.Pending
+import net.taler.wallet.transactions.TransactionState
+import net.taler.wallet.transactions.TransactionWithdrawal
+import net.taler.wallet.transactions.TransitionsComposable
+import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer
+import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails
+
+@Composable
+fun TransactionWithdrawalComposable(
+ t: TransactionWithdrawal,
+ devMode: Boolean,
+ spec: CurrencySpecification?,
+ actionListener: ActionListener,
+ onTransition: (t: TransactionAction) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ val context = LocalContext.current
+ Text(
+ modifier = Modifier.padding(16.dp),
+ text = t.timestamp.ms.toAbsoluteTime(context).toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+
+ ActionButton(tx = t, listener = actionListener)
+
+ if (t.amountRaw != t.amountEffective) {
+ TransactionAmountComposable(
+ label = stringResource(R.string.amount_chosen),
+ amount = t.amountRaw.withSpec(spec),
+ amountType = AmountType.Neutral,
+ )
+ }
+
+ if (t.amountRaw > t.amountEffective) {
+ val fee = t.amountRaw - t.amountEffective
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee.withSpec(spec),
+ amountType = AmountType.Negative,
+ )
+ }
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_total),
+ amount = t.amountEffective.withSpec(spec),
+ amountType = AmountType.Positive,
+ )
+
+ TransactionInfoComposable(
+ label = stringResource(id = R.string.withdraw_exchange),
+ info = cleanExchange(t.exchangeBaseUrl),
+ )
+
+ TransitionsComposable(t, devMode, onTransition)
+
+ if (devMode && t.error != null) {
+ ErrorTransactionButton(error = t.error)
+ }
+ }
+}
+
+@Preview
+@Composable
+fun TransactionWithdrawalComposablePreview() {
+ val t = TransactionWithdrawal(
+ transactionId = "transactionId",
+ timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000),
+ txState = TransactionState(Pending),
+ txActions = listOf(Retry, Suspend, Abort),
+ exchangeBaseUrl = "https://exchange.demo.taler.net/",
+ withdrawalDetails = ManualTransfer(
+ exchangeCreditAccountDetails = listOf(
+ WithdrawalExchangeAccountDetails(
+ paytoUri = "payto://IBAN/1231231231",
+ transferAmount = Amount.fromJSONString("NETZBON:42.23"),
+ status = WithdrawalExchangeAccountDetails.Status.Ok,
+ currencySpecification = CurrencySpecification(
+ name = "NETZBON",
+ numFractionalInputDigits = 2,
+ numFractionalNormalDigits = 2,
+ numFractionalTrailingZeroDigits = 2,
+ altUnitNames = mapOf(0 to "NETZBON"),
+ ),
+ ),
+ ),
+ ),
+ amountRaw = Amount.fromString("TESTKUDOS", "42.23"),
+ amountEffective = Amount.fromString("TESTKUDOS", "42.1337"),
+ error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED),
+ )
+
+ val listener = object : ActionListener {
+ override fun onActionButtonClicked(tx: Transaction, type: ActionListener.Type) {}
+ }
+
+ Surface {
+ TransactionWithdrawalComposable(t, true, null, listener) {}
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt
index 1698a10..e308b2a 100644
--- a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt
@@ -19,7 +19,7 @@ package net.taler.wallet.withdraw
import android.net.Uri
import android.util.Log
import androidx.annotation.UiThread
-import androidx.lifecycle.LiveData
+import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -31,23 +31,32 @@ import net.taler.common.toEvent
import net.taler.wallet.TAG
import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.backend.WalletBackendApi
+import net.taler.wallet.balances.ScopeInfo
import net.taler.wallet.exchanges.ExchangeFees
import net.taler.wallet.exchanges.ExchangeItem
+import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails
import net.taler.wallet.withdraw.WithdrawStatus.ReceivedDetails
sealed class WithdrawStatus {
data class Loading(val talerWithdrawUri: String? = null) : WithdrawStatus()
- data class NeedsExchange(val exchangeSelection: Event<ExchangeSelection>) : WithdrawStatus()
+
+ data class NeedsExchange(
+ val talerWithdrawUri: String,
+ val amount: Amount,
+ val possibleExchanges: List<ExchangeItem>,
+ ) : WithdrawStatus()
data class TosReviewRequired(
val talerWithdrawUri: String? = null,
val exchangeBaseUrl: String,
val amountRaw: Amount,
val amountEffective: Amount,
+ val withdrawalAccountList: List<WithdrawalExchangeAccountDetails>,
val ageRestrictionOptions: List<Int>? = null,
val tosText: String,
val tosEtag: String,
val showImmediately: Event<Boolean>,
+ val possibleExchanges: List<ExchangeItem> = emptyList(),
) : WithdrawStatus()
data class ReceivedDetails(
@@ -55,38 +64,62 @@ sealed class WithdrawStatus {
val exchangeBaseUrl: String,
val amountRaw: Amount,
val amountEffective: Amount,
+ val withdrawalAccountList: List<WithdrawalExchangeAccountDetails>,
val ageRestrictionOptions: List<Int>? = null,
+ val possibleExchanges: List<ExchangeItem> = emptyList(),
) : WithdrawStatus()
- object Withdrawing : WithdrawStatus()
- data class Success(val currency: String) : WithdrawStatus()
- sealed class ManualTransferRequired : WithdrawStatus() {
- abstract val uri: Uri
- abstract val transactionId: String?
- }
+ data object Withdrawing : WithdrawStatus()
- data class ManualTransferRequiredIBAN(
- val exchangeBaseUrl: String,
- override val uri: Uri,
- val iban: String,
- val subject: String,
- val amountRaw: Amount,
- override val transactionId: String?,
- ) : ManualTransferRequired()
+ data class Success(val currency: String, val transactionId: String) : WithdrawStatus()
- data class ManualTransferRequiredBitcoin(
+ class ManualTransferRequired(
+ val transactionId: String?,
+ val transactionAmountRaw: Amount,
+ val transactionAmountEffective: Amount,
val exchangeBaseUrl: String,
- override val uri: Uri,
- val account: String,
- val segwitAddrs: List<String>,
- val subject: String,
- val amountRaw: Amount,
- override val transactionId: String?,
- ) : ManualTransferRequired()
+ val withdrawalTransfers: List<TransferData>,
+ ) : WithdrawStatus()
data class Error(val message: String?) : WithdrawStatus()
}
+sealed class TransferData {
+ abstract val subject: String
+ abstract val amountRaw: Amount
+ abstract val amountEffective: Amount
+ abstract val withdrawalAccount: WithdrawalExchangeAccountDetails
+
+ val currency get() = withdrawalAccount.transferAmount?.currency
+
+ data class Taler(
+ override val subject: String,
+ override val amountRaw: Amount,
+ override val amountEffective: Amount,
+ override val withdrawalAccount: WithdrawalExchangeAccountDetails,
+ val receiverName: String? = null,
+ val account: String,
+ ): TransferData()
+
+ data class IBAN(
+ override val subject: String,
+ override val amountRaw: Amount,
+ override val amountEffective: Amount,
+ override val withdrawalAccount: WithdrawalExchangeAccountDetails,
+ val receiverName: String? = null,
+ val iban: String,
+ ): TransferData()
+
+ data class Bitcoin(
+ override val subject: String,
+ override val amountRaw: Amount,
+ override val amountEffective: Amount,
+ override val withdrawalAccount: WithdrawalExchangeAccountDetails,
+ val account: String,
+ val segwitAddresses: List<String>,
+ ): TransferData()
+}
+
sealed class WithdrawTestStatus {
object Withdrawing : WithdrawTestStatus()
object Success : WithdrawTestStatus()
@@ -101,21 +134,31 @@ data class WithdrawalDetailsForUri(
)
@Serializable
-data class WithdrawalDetails(
+data class WithdrawExchangeResponse(
+ val exchangeBaseUrl: String,
+ val amount: Amount? = null,
+)
+
+@Serializable
+data class ManualWithdrawalDetails(
val tosAccepted: Boolean,
val amountRaw: Amount,
val amountEffective: Amount,
+ val withdrawalAccountsList: List<WithdrawalExchangeAccountDetails>,
val ageRestrictionOptions: List<Int>? = null,
+ val scopeInfo: ScopeInfo,
)
@Serializable
-data class AcceptManualWithdrawalResponse(
- val exchangePaytoUris: List<String>,
+data class AcceptWithdrawalResponse(
+ val transactionId: String,
)
-data class ExchangeSelection(
- val amount: Amount,
- val talerWithdrawUri: String,
+@Serializable
+data class AcceptManualWithdrawalResponse(
+ val reservePub: String,
+ val withdrawalAccountsList: List<WithdrawalExchangeAccountDetails>,
+ val transactionId: String,
)
class WithdrawManager(
@@ -126,8 +169,6 @@ class WithdrawManager(
val withdrawStatus = MutableLiveData<WithdrawStatus>()
val testWithdrawalStatus = MutableLiveData<WithdrawTestStatus>()
- private val _exchangeSelection = MutableLiveData<Event<ExchangeSelection>>()
- val exchangeSelection: LiveData<Event<ExchangeSelection>> = _exchangeSelection
var exchangeFees: ExchangeFees? = null
private set
@@ -140,11 +181,6 @@ class WithdrawManager(
}
}
- @UiThread
- fun selectExchange(selection: ExchangeSelection) {
- _exchangeSelection.value = selection.toEvent()
- }
-
fun getWithdrawalDetails(uri: String) = scope.launch {
withdrawStatus.value = WithdrawStatus.Loading(uri)
api.request("getWithdrawalDetailsForUri", WithdrawalDetailsForUri.serializer()) {
@@ -153,13 +189,17 @@ class WithdrawManager(
handleError("getWithdrawalDetailsForUri", error)
}.onSuccess { details ->
if (details.defaultExchangeBaseUrl == null) {
- val exchangeSelection = ExchangeSelection(details.amount, uri)
- withdrawStatus.value = WithdrawStatus.NeedsExchange(exchangeSelection.toEvent())
+ withdrawStatus.value = WithdrawStatus.NeedsExchange(
+ talerWithdrawUri = uri,
+ amount = details.amount,
+ possibleExchanges = details.possibleExchanges,
+ )
} else getWithdrawalDetails(
exchangeBaseUrl = details.defaultExchangeBaseUrl,
amount = details.amount,
showTosImmediately = false,
uri = uri,
+ possibleExchanges = details.possibleExchanges,
)
}
}
@@ -169,9 +209,10 @@ class WithdrawManager(
amount: Amount,
showTosImmediately: Boolean = false,
uri: String? = null,
+ possibleExchanges: List<ExchangeItem> = emptyList(),
) = scope.launch {
withdrawStatus.value = WithdrawStatus.Loading(uri)
- api.request("getWithdrawalDetailsForAmount", WithdrawalDetails.serializer()) {
+ api.request("getWithdrawalDetailsForAmount", ManualWithdrawalDetails.serializer()) {
put("exchangeBaseUrl", exchangeBaseUrl)
put("amount", amount.toJSONString())
}.onError { error ->
@@ -183,17 +224,34 @@ class WithdrawManager(
exchangeBaseUrl = exchangeBaseUrl,
amountRaw = details.amountRaw,
amountEffective = details.amountEffective,
+ withdrawalAccountList = details.withdrawalAccountsList,
ageRestrictionOptions = details.ageRestrictionOptions,
+ possibleExchanges = possibleExchanges,
)
- } else getExchangeTos(exchangeBaseUrl, details, showTosImmediately, uri)
+ } else getExchangeTos(exchangeBaseUrl, details, showTosImmediately, uri, possibleExchanges)
+ }
+ }
+
+ @WorkerThread
+ suspend fun prepareManualWithdrawal(uri: String): WithdrawExchangeResponse? {
+ withdrawStatus.postValue(WithdrawStatus.Loading(uri))
+ var response: WithdrawExchangeResponse? = null
+ api.request("prepareWithdrawExchange", WithdrawExchangeResponse.serializer()) {
+ put("talerUri", uri)
+ }.onError {
+ handleError("prepareWithdrawExchange", it)
+ }.onSuccess {
+ response = it
}
+ return response
}
private fun getExchangeTos(
exchangeBaseUrl: String,
- details: WithdrawalDetails,
+ details: ManualWithdrawalDetails,
showImmediately: Boolean,
uri: String?,
+ possibleExchanges: List<ExchangeItem>,
) = scope.launch {
api.request("getExchangeTos", TosResponse.serializer()) {
put("exchangeBaseUrl", exchangeBaseUrl)
@@ -205,10 +263,12 @@ class WithdrawManager(
exchangeBaseUrl = exchangeBaseUrl,
amountRaw = details.amountRaw,
amountEffective = details.amountEffective,
+ withdrawalAccountList = details.withdrawalAccountsList,
ageRestrictionOptions = details.ageRestrictionOptions,
tosText = it.content,
tosEtag = it.currentEtag,
showImmediately = showImmediately.toEvent(),
+ possibleExchanges = possibleExchanges,
)
}
}
@@ -229,7 +289,9 @@ class WithdrawManager(
exchangeBaseUrl = s.exchangeBaseUrl,
amountRaw = s.amountRaw,
amountEffective = s.amountEffective,
+ withdrawalAccountList = s.withdrawalAccountList,
ageRestrictionOptions = s.ageRestrictionOptions,
+ possibleExchanges = s.possibleExchanges,
)
}
}
@@ -249,14 +311,15 @@ class WithdrawManager(
status: ReceivedDetails,
restrictAge: Int? = null,
) {
- api.request<Unit>("acceptBankIntegratedWithdrawal") {
+ api.request("acceptBankIntegratedWithdrawal", AcceptWithdrawalResponse.serializer()) {
restrictAge?.let { put("restrictAge", restrictAge) }
put("exchangeBaseUrl", status.exchangeBaseUrl)
put("talerWithdrawUri", status.talerWithdrawUri)
}.onError {
handleError("acceptBankIntegratedWithdrawal", it)
}.onSuccess {
- withdrawStatus.value = WithdrawStatus.Success(status.amountRaw.currency)
+ withdrawStatus.value =
+ WithdrawStatus.Success(status.amountRaw.currency, it.transactionId)
}
}
@@ -269,18 +332,15 @@ class WithdrawManager(
handleError("acceptManualWithdrawal", it)
}.onSuccess { response ->
withdrawStatus.value = createManualTransferRequired(
- amount = status.amountRaw,
- exchangeBaseUrl = status.exchangeBaseUrl,
- // TODO what if there's more than one or no URI?
- uriStr = response.exchangePaytoUris[0],
+ status = status,
+ response = response,
)
}
}
- @UiThread
private fun handleError(operation: String, error: TalerErrorInfo) {
Log.e(TAG, "Error $operation $error")
- withdrawStatus.value = WithdrawStatus.Error(error.userFacingMsg)
+ withdrawStatus.postValue(WithdrawStatus.Error(error.userFacingMsg))
}
/**
@@ -295,33 +355,60 @@ class WithdrawManager(
}
fun createManualTransferRequired(
- amount: Amount,
+ transactionId: String,
exchangeBaseUrl: String,
- uriStr: String,
- transactionId: String? = null,
-): WithdrawStatus.ManualTransferRequired {
- val uri = Uri.parse(uriStr.replace("receiver-name=", "receiver_name="))
- if ("bitcoin".equals(uri.authority, true)) {
- val msg = uri.getQueryParameter("message").orEmpty()
- val reg = "\\b([A-Z0-9]{52})\\b".toRegex().find(msg)
- val reserve = reg?.value ?: uri.getQueryParameter("subject")!!
- val segwitAddrs = Bech32.generateFakeSegwitAddress(reserve, uri.pathSegments.first())
- return WithdrawStatus.ManualTransferRequiredBitcoin(
- exchangeBaseUrl = exchangeBaseUrl,
- uri = uri,
- account = uri.lastPathSegment!!,
- segwitAddrs = segwitAddrs,
- subject = reserve,
- amountRaw = amount,
- transactionId = transactionId,
- )
- }
- return WithdrawStatus.ManualTransferRequiredIBAN(
- exchangeBaseUrl = exchangeBaseUrl,
- uri = uri,
- iban = uri.lastPathSegment!!,
- subject = uri.getQueryParameter("message") ?: "Error: No message in URI",
- amountRaw = amount,
- transactionId = transactionId,
- )
-}
+ amountRaw: Amount,
+ amountEffective: Amount,
+ withdrawalAccountList: List<WithdrawalExchangeAccountDetails>,
+) = WithdrawStatus.ManualTransferRequired(
+ transactionId = transactionId,
+ transactionAmountRaw = amountRaw,
+ transactionAmountEffective = amountEffective,
+ exchangeBaseUrl = exchangeBaseUrl,
+ withdrawalTransfers = withdrawalAccountList.mapNotNull {
+ val uri = Uri.parse(it.paytoUri.replace("receiver-name=", "receiver_name="))
+ if ("bitcoin".equals(uri.authority, true)) {
+ val msg = uri.getQueryParameter("message").orEmpty()
+ val reg = "\\b([A-Z0-9]{52})\\b".toRegex().find(msg)
+ val reserve = reg?.value ?: uri.getQueryParameter("subject")!!
+ val segwitAddresses = Bech32.generateFakeSegwitAddress(reserve, uri.pathSegments.first())
+ TransferData.Bitcoin(
+ account = uri.lastPathSegment!!,
+ segwitAddresses = segwitAddresses,
+ subject = reserve,
+ amountRaw = amountRaw,
+ amountEffective = amountEffective,
+ withdrawalAccount = it.copy(paytoUri = uri.toString())
+ )
+ } else if (uri.authority.equals("x-taler-bank", true)) {
+ TransferData.Taler(
+ account = uri.lastPathSegment!!,
+ receiverName = uri.getQueryParameter("receiver_name"),
+ subject = uri.getQueryParameter("message") ?: "Error: No message in URI",
+ amountRaw = amountRaw,
+ amountEffective = amountEffective,
+ withdrawalAccount = it.copy(paytoUri = uri.toString()),
+ )
+ } else if (uri.authority.equals("iban", true)) {
+ TransferData.IBAN(
+ iban = uri.lastPathSegment!!,
+ receiverName = uri.getQueryParameter("receiver_name"),
+ subject = uri.getQueryParameter("message") ?: "Error: No message in URI",
+ amountRaw = amountRaw,
+ amountEffective = amountEffective,
+ withdrawalAccount = it.copy(paytoUri = uri.toString()),
+ )
+ } else null
+ },
+)
+
+fun createManualTransferRequired(
+ status: ReceivedDetails,
+ response: AcceptManualWithdrawalResponse,
+): WithdrawStatus.ManualTransferRequired = createManualTransferRequired(
+ transactionId = response.transactionId,
+ exchangeBaseUrl = status.exchangeBaseUrl,
+ amountRaw = status.amountRaw,
+ amountEffective = status.amountEffective,
+ withdrawalAccountList = response.withdrawalAccountsList,
+) \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawFragment.kt
index aae8c95..e829bb1 100644
--- a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawFragment.kt
@@ -20,11 +20,13 @@ import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
+import android.view.View.GONE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import net.taler.common.Amount
+import net.taler.common.AmountParserException
import net.taler.common.hideKeyboard
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
@@ -54,6 +56,13 @@ class ManualWithdrawFragment : Fragment() {
ui.amountView.setText(amount.amountStr)
}
+ arguments?.getBoolean("hideScanQr")?.let {
+ if (it) {
+ ui.qrCodeButton.visibility = GONE
+ ui.orView.visibility = GONE
+ }
+ }
+
ui.qrCodeButton.setOnClickListener {
model.scanCode()
}
@@ -73,14 +82,13 @@ class ManualWithdrawFragment : Fragment() {
return
}
ui.amountLayout.error = null
- val value: Double
+ val amount: Amount
try {
- value = ui.amountView.text.toString().replace(',', '.').toDouble()
- } catch (e: NumberFormatException) {
+ amount = Amount.fromString(currency, ui.amountView.text.toString())
+ } catch (e: AmountParserException) {
ui.amountLayout.error = getString(R.string.withdraw_amount_error)
return
}
- val amount = Amount.fromDouble(currency, value)
ui.amountView.hideKeyboard()
withdrawManager.getWithdrawalDetails(exchangeItem.exchangeBaseUrl, amount)
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt
index 3102123..63413c2 100644
--- a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt
@@ -16,59 +16,78 @@
package net.taler.wallet.withdraw.manual
-import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
-import net.taler.common.startActivitySafe
+import net.taler.common.openUri
+import net.taler.common.shareText
import net.taler.wallet.MainViewModel
import net.taler.wallet.R
import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.withdraw.TransferData
import net.taler.wallet.withdraw.WithdrawStatus
class ManualWithdrawSuccessFragment : Fragment() {
private val model: MainViewModel by activityViewModels()
- private val transactionManager by lazy { model.transactionManager }
private val withdrawManager by lazy { model.withdrawManager }
+ private val balanceManager by lazy { model.balanceManager }
+
+ private lateinit var status: WithdrawStatus.ManualTransferRequired
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?,
): View = ComposeView(requireContext()).apply {
- val status = withdrawManager.withdrawStatus.value as WithdrawStatus.ManualTransferRequired
- val intent = Intent().apply {
- data = status.uri
- }
- // TODO test if this works with an actual payto:// handling app
- val componentName = intent.resolveActivity(requireContext().packageManager)
- val onBankAppClick = if (componentName == null) null else {
- { requireContext().startActivitySafe(intent) }
- }
- val tid = status.transactionId
- val onCancelClick = if (tid == null) null else {
- {
- transactionManager.deleteTransaction(tid)
- findNavController().navigate(R.id.action_nav_exchange_manual_withdrawal_success_to_nav_main)
+ status = withdrawManager.withdrawStatus.value as WithdrawStatus.ManualTransferRequired
+
+ // Set action bar subtitle and unset on exit
+ if (status.withdrawalTransfers.size > 1) {
+ val activity = requireActivity() as AppCompatActivity
+
+ activity.apply {
+ supportActionBar?.subtitle = getString(R.string.withdraw_subtitle)
+ }
+
+ findNavController().addOnDestinationChangedListener { controller, destination, args ->
+ if (destination.id != R.id.nav_exchange_manual_withdrawal_success) {
+ activity.apply {
+ supportActionBar?.subtitle = null
+ }
+ }
}
}
+
setContent {
TalerSurface {
- when (status) {
- is WithdrawStatus.ManualTransferRequiredBitcoin -> {
- ScreenBitcoin(status, onBankAppClick, onCancelClick)
- }
- is WithdrawStatus.ManualTransferRequiredIBAN -> {
- ScreenIBAN(status, onBankAppClick, onCancelClick)
- }
- }
+ ScreenTransfer(
+ status = status,
+ spec = balanceManager.getSpecForCurrency(status.transactionAmountRaw.currency),
+ bankAppClick = { onBankAppClick(it) },
+ shareClick = { onShareClick(it) },
+ )
}
}
}
+ private fun onBankAppClick(transfer: TransferData) {
+ requireContext().openUri(
+ uri = transfer.withdrawalAccount.paytoUri,
+ title = requireContext().getString(R.string.share_payment)
+ )
+ }
+
+ private fun onShareClick(transfer: TransferData) {
+ requireContext().shareText(
+ text = transfer.withdrawalAccount.paytoUri,
+ )
+ }
+
override fun onStart() {
super.onStart()
activity?.setTitle(R.string.withdraw_title)
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenBitcoin.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenBitcoin.kt
deleted file mode 100644
index d6ed7df..0000000
--- a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenBitcoin.kt
+++ /dev/null
@@ -1,171 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2022 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.withdraw.manual
-
-import android.net.Uri
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.wrapContentWidth
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.ButtonDefaults
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Alignment.Companion.End
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.em
-import net.taler.common.Amount
-import net.taler.wallet.CURRENCY_BTC
-import net.taler.wallet.R
-import net.taler.wallet.compose.CopyToClipboardButton
-import net.taler.wallet.withdraw.WithdrawStatus
-
-@Composable
-fun ScreenBitcoin(
- status: WithdrawStatus.ManualTransferRequiredBitcoin,
- bankAppClick: (() -> Unit)?,
- onCancelClick: (() -> Unit)?,
-) {
- val scrollState = rememberScrollState()
- Column(modifier = Modifier
- .wrapContentWidth(Alignment.CenterHorizontally)
- .verticalScroll(scrollState)
- .padding(all = 16.dp)
- ) {
- Text(
- text = stringResource(R.string.withdraw_manual_bitcoin_title),
- style = MaterialTheme.typography.h5,
- )
- Text(
- text = stringResource(R.string.withdraw_manual_bitcoin_intro),
- style = MaterialTheme.typography.body1,
- modifier = Modifier
- .padding(vertical = 8.dp)
- )
- BitcoinSegwitAddrs(
- amount = status.amountRaw,
- addr = status.account,
- segwitAddresses = status.segwitAddrs
- )
- if (bankAppClick != null) {
- Button(
- onClick = bankAppClick,
- modifier = Modifier
- .padding(vertical = 16.dp)
- .align(Alignment.CenterHorizontally),
- ) {
- Text(text = stringResource(R.string.withdraw_manual_ready_bank_button))
- }
- }
- if (onCancelClick != null) {
- Button(
- onClick = onCancelClick,
- colors = ButtonDefaults.buttonColors(backgroundColor = colorResource(R.color.red)),
- modifier = Modifier
- .padding(vertical = 16.dp)
- .align(End),
- ) {
- Text(
- text = stringResource(R.string.withdraw_manual_ready_cancel),
- color = Color.White,
- )
- }
- }
- }
-}
-
-@Composable
-fun BitcoinSegwitAddrs(amount: Amount, addr: String, segwitAddresses: List<String>) {
- Column {
- CopyToClipboardButton(
- modifier = Modifier.align(End),
- label = "Bitcoin",
- content = getCopyText(amount, addr, segwitAddresses),
- )
- Row(modifier = Modifier.padding(vertical = 8.dp)) {
- Column(modifier = Modifier.weight(0.3f)) {
- Text(
- text = addr,
- style = MaterialTheme.typography.body1,
- fontWeight = FontWeight.Normal,
- fontSize = 3.em
- )
- Text(
- text = amount.withCurrency("BTC").toString(),
- style = MaterialTheme.typography.body1,
- fontWeight = FontWeight.Bold,
- )
- }
- }
- for (segwitAddress in segwitAddresses) {
- Row(modifier = Modifier.padding(vertical = 8.dp)) {
- Column(modifier = Modifier.weight(0.3f)) {
- Text(
- text = segwitAddress,
- style = MaterialTheme.typography.body1,
- fontWeight = FontWeight.Normal,
- fontSize = 3.em,
- )
- Text(
- text = SEGWIT_MIN.toString(),
- style = MaterialTheme.typography.body1,
- fontWeight = FontWeight.Bold,
- )
- }
- }
- }
- }
-}
-
-private val SEGWIT_MIN = Amount("BTC", 0, 294)
-
-private fun getCopyText(amount: Amount, addr: String, segwitAddresses: List<String>): String {
- val sr = segwitAddresses.joinToString(separator = "\n") { s ->
- "\n$s ${SEGWIT_MIN}\n"
- }
- return "$addr ${amount.withCurrency("BTC")}\n$sr"
-}
-
-@Preview
-@Composable
-fun PreviewScreenBitcoin() {
- Surface {
- ScreenBitcoin(WithdrawStatus.ManualTransferRequiredBitcoin(
- exchangeBaseUrl = "bitcoin.ice.bfh.ch",
- uri = Uri.parse("https://taler.net"),
- account = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
- segwitAddrs = listOf(
- "bc1qqleages8702xvg9qcyu02yclst24xurdrynvxq",
- "bc1qsleagehks96u7jmqrzcf0fw80ea5g57qm3m84c"
- ),
- subject = "0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00",
- amountRaw = Amount(CURRENCY_BTC, 0, 14000000),
- transactionId = "",
- ), {}) {}
- }
-}
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenIBAN.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenIBAN.kt
deleted file mode 100644
index 79ca364..0000000
--- a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenIBAN.kt
+++ /dev/null
@@ -1,161 +0,0 @@
-/*
- * This file is part of GNU Taler
- * (C) 2022 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.withdraw.manual
-
-import android.net.Uri
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.wrapContentWidth
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.Button
-import androidx.compose.material.ButtonDefaults
-import androidx.compose.material.Icon
-import androidx.compose.material.IconButton
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ContentCopy
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import net.taler.common.Amount
-import net.taler.wallet.R
-import net.taler.wallet.compose.copyToClipBoard
-import net.taler.wallet.withdraw.WithdrawStatus
-
-@Composable
-fun ScreenIBAN(
- status: WithdrawStatus.ManualTransferRequiredIBAN,
- bankAppClick: (() -> Unit)?,
- onCancelClick: (() -> Unit)?,
-) {
- val scrollState = rememberScrollState()
- Column(modifier = Modifier
- .wrapContentWidth(Alignment.CenterHorizontally)
- .verticalScroll(scrollState)
- .padding(all = 16.dp)
- ) {
- Text(
- text = stringResource(R.string.withdraw_manual_ready_title),
- style = MaterialTheme.typography.h5,
- )
- Text(
- text = stringResource(R.string.withdraw_manual_ready_intro,
- status.amountRaw.toString()),
- style = MaterialTheme.typography.body1,
- modifier = Modifier
- .padding(vertical = 8.dp)
- )
- DetailRow(stringResource(R.string.withdraw_manual_ready_iban), status.iban)
- DetailRow(stringResource(R.string.withdraw_manual_ready_subject), status.subject)
- DetailRow(stringResource(R.string.amount_chosen), status.amountRaw.toString())
- DetailRow(stringResource(R.string.withdraw_exchange), status.exchangeBaseUrl, false)
- Text(
- text = stringResource(R.string.withdraw_manual_ready_warning),
- style = MaterialTheme.typography.body2,
- color = colorResource(R.color.notice_text),
- modifier = Modifier
- .align(Alignment.CenterHorizontally)
- .padding(all = 8.dp)
- .background(colorResource(R.color.notice_background))
- .border(BorderStroke(2.dp, colorResource(R.color.notice_border)))
- .padding(all = 16.dp)
- )
- if (bankAppClick != null) {
- Button(
- onClick = bankAppClick,
- modifier = Modifier
- .padding(vertical = 16.dp)
- .align(Alignment.CenterHorizontally),
- ) {
- Text(text = stringResource(R.string.withdraw_manual_ready_bank_button))
- }
- }
- if (onCancelClick != null) {
- Button(
- onClick = onCancelClick,
- colors = ButtonDefaults.buttonColors(backgroundColor = colorResource(R.color.red)),
- modifier = Modifier
- .padding(vertical = 16.dp)
- .align(Alignment.End),
- ) {
- Text(
- text = stringResource(R.string.withdraw_manual_ready_cancel),
- color = Color.White,
- )
- }
- }
- }
-}
-
-@Composable
-fun DetailRow(label: String, content: String, copy: Boolean = true) {
- val context = LocalContext.current
- Row {
- Column(
- modifier = Modifier
- .weight(0.3f)) {
- Text(
- text = label,
- style = MaterialTheme.typography.body1,
- fontWeight = if (copy) FontWeight.Bold else FontWeight.Normal,
- )
- if (copy) {
- IconButton(
- onClick = { copyToClipBoard(context, label, content) },
- ) { Icon(Icons.Default.ContentCopy, stringResource(R.string.copy)) }
- }
- }
- Text(
- text = content,
- style = MaterialTheme.typography.body1,
- modifier = Modifier
- .padding(bottom = 8.dp)
- .weight(0.7f)
- .then(if (copy) Modifier else Modifier.alpha(0.7f))
- )
- }
-}
-
-@Preview
-@Composable
-fun PreviewScreenIBAN() {
- Surface {
- ScreenIBAN(WithdrawStatus.ManualTransferRequiredIBAN(
- exchangeBaseUrl = "test.exchange.taler.net",
- uri = Uri.parse("https://taler.net"),
- iban = "ASDQWEASDZXCASDQWE",
- subject = "Taler Withdrawal P2T19EXRBY4B145JRNZ8CQTD7TCS03JE9VZRCEVKVWCP930P56WG",
- amountRaw = Amount("KUDOS", 10, 0),
- transactionId = "",
- ), {}) {}
- }
-}
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt
new file mode 100644
index 0000000..00495fb
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt
@@ -0,0 +1,326 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.withdraw.manual
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ScrollableTabRow
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.wallet.CURRENCY_BTC
+import net.taler.wallet.R
+import net.taler.common.CurrencySpecification
+import net.taler.wallet.compose.ShareButton
+import net.taler.wallet.compose.copyToClipBoard
+import net.taler.wallet.transactions.AmountType
+import net.taler.wallet.transactions.TransactionAmountComposable
+import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails
+import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails.Status.*
+import net.taler.wallet.withdraw.TransferData
+import net.taler.wallet.withdraw.WithdrawStatus
+
+@Composable
+fun ScreenTransfer(
+ status: WithdrawStatus.ManualTransferRequired,
+ spec: CurrencySpecification?,
+ bankAppClick: ((transfer: TransferData) -> Unit)?,
+ shareClick: ((transfer: TransferData) -> Unit)?,
+) {
+ // TODO: show some placeholder
+ if (status.withdrawalTransfers.isEmpty()) return
+
+ val transfers = status.withdrawalTransfers.filter {
+ // TODO: in dev mode, show debug info when status is `Error'
+ it.withdrawalAccount.status == Ok
+ }.sortedByDescending {
+ it.withdrawalAccount.priority
+ }
+
+ val defaultTransfer = transfers[0]
+ var selectedTransfer by remember { mutableStateOf(defaultTransfer) }
+
+ Column {
+ if (status.withdrawalTransfers.size > 1) {
+ TransferAccountChooser(
+ accounts = transfers.map { it.withdrawalAccount },
+ selectedAccount = selectedTransfer.withdrawalAccount,
+ onSelectAccount = { account ->
+ status.withdrawalTransfers.find {
+ it.withdrawalAccount.paytoUri == account.paytoUri
+ }?.let { selectedTransfer = it }
+ }
+ )
+ }
+
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .verticalScroll(scrollState),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ when (val transfer = selectedTransfer) {
+ is TransferData.Taler -> TransferTaler(
+ transfer = transfer,
+ exchangeBaseUrl = status.exchangeBaseUrl,
+ transactionAmountRaw = status.transactionAmountRaw.withSpec(spec),
+ transactionAmountEffective = status.transactionAmountEffective.withSpec(spec),
+ )
+
+ is TransferData.IBAN -> TransferIBAN(
+ transfer = transfer,
+ exchangeBaseUrl = status.exchangeBaseUrl,
+ transactionAmountRaw = status.transactionAmountRaw.withSpec(spec),
+ transactionAmountEffective = status.transactionAmountEffective.withSpec(spec),
+ )
+
+ is TransferData.Bitcoin -> TransferBitcoin(
+ transfer = transfer,
+ transactionAmountRaw = status.transactionAmountRaw.withSpec(spec),
+ transactionAmountEffective = status.transactionAmountEffective.withSpec(spec),
+ )
+ }
+
+ if (bankAppClick != null) {
+ Button(
+ onClick = { bankAppClick(selectedTransfer) },
+ modifier = Modifier
+ .padding(bottom = 16.dp),
+ ) {
+ Text(text = stringResource(R.string.withdraw_manual_ready_bank_button))
+ }
+ }
+
+ if (shareClick != null) {
+ ShareButton(
+ content = selectedTransfer.withdrawalAccount.paytoUri,
+ modifier = Modifier
+ .padding(bottom = 16.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun DetailRow(
+ label: String,
+ content: String,
+ copy: Boolean = true,
+) {
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ modifier = Modifier.padding(top = 16.dp, start = 6.dp, end = 6.dp),
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+
+ Text(
+ modifier = Modifier.padding(
+ top = 8.dp,
+ start = 6.dp,
+ end = 6.dp,
+ ),
+ text = content,
+ style = MaterialTheme.typography.bodyLarge,
+ fontFamily = if (copy) FontFamily.Monospace else FontFamily.Default,
+ textAlign = TextAlign.Center,
+ )
+
+ if (copy) {
+ IconButton(
+ onClick = { copyToClipBoard(context, label, content) },
+ ) {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = stringResource(R.string.copy),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun WithdrawalAmountTransfer(
+ amountRaw: Amount,
+ amountEffective: Amount,
+ conversionAmountRaw: Amount,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ TransactionAmountComposable(
+ label = stringResource(R.string.amount_transfer),
+ amount = conversionAmountRaw,
+ amountType = AmountType.Neutral,
+ )
+
+ if (amountRaw.currency != conversionAmountRaw.currency) {
+ TransactionAmountComposable(
+ label = stringResource(R.string.amount_conversion),
+ amount = amountRaw,
+ amountType = AmountType.Neutral,
+ )
+ }
+
+ if (amountRaw > amountEffective) {
+ val fee = amountRaw - amountEffective
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_fee),
+ amount = fee,
+ amountType = AmountType.Negative,
+ )
+
+ TransactionAmountComposable(
+ label = stringResource(id = R.string.amount_total),
+ amount = amountEffective,
+ amountType = AmountType.Positive,
+ )
+ }
+ }
+}
+
+@Composable
+fun TransferAccountChooser(
+ modifier: Modifier = Modifier,
+ accounts: List<WithdrawalExchangeAccountDetails>,
+ selectedAccount: WithdrawalExchangeAccountDetails,
+ onSelectAccount: (account: WithdrawalExchangeAccountDetails) -> Unit,
+) {
+ val selectedIndex = accounts.indexOfFirst {
+ it.paytoUri == selectedAccount.paytoUri
+ }
+
+ ScrollableTabRow(
+ selectedTabIndex = selectedIndex,
+ modifier = modifier,
+ edgePadding = 8.dp,
+ ) {
+ accounts.forEachIndexed { index, account ->
+ Tab(
+ selected = selectedAccount.paytoUri == account.paytoUri,
+ onClick = { onSelectAccount(account) },
+ text = {
+ if (!account.bankLabel.isNullOrEmpty()) {
+ Text(account.bankLabel)
+ } else if (account.currencySpecification?.name != null) {
+ Text(stringResource(
+ R.string.withdraw_account_currency,
+ index + 1,
+ account.currencySpecification.name,
+ ))
+ } else if (account.transferAmount?.currency != null) {
+ Text(stringResource(
+ R.string.withdraw_account_currency,
+ index + 1,
+ account.transferAmount.currency,
+ ))
+ } else Text(stringResource(R.string.withdraw_account, index + 1))
+ },
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ScreenTransferPreview() {
+ Surface {
+ ScreenTransfer(
+ status = WithdrawStatus.ManualTransferRequired(
+ transactionId = "",
+ transactionAmountRaw = Amount.fromJSONString("KUDOS:10"),
+ transactionAmountEffective = Amount.fromJSONString("KUDOS:9.5"),
+ exchangeBaseUrl = "test.exchange.taler.net",
+ withdrawalTransfers = listOf(
+ TransferData.IBAN(
+ iban = "ASDQWEASDZXCASDQWE",
+ subject = "Taler Withdrawal P2T19EXRBY4B145JRNZ8CQTD7TCS03JE9VZRCEVKVWCP930P56WG",
+ amountRaw = Amount("KUDOS", 10, 0),
+ amountEffective = Amount("KUDOS", 9, 5),
+ withdrawalAccount = WithdrawalExchangeAccountDetails(
+ paytoUri = "https://taler.net/kudos",
+ transferAmount = Amount("KUDOS", 10, 0),
+ status = Ok,
+ currencySpecification = CurrencySpecification(
+ "KUDOS",
+ numFractionalInputDigits = 2,
+ numFractionalNormalDigits = 2,
+ numFractionalTrailingZeroDigits = 2,
+ altUnitNames = emptyMap(),
+ ),
+ ),
+ ),
+ TransferData.Bitcoin(
+ account = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
+ segwitAddresses = listOf(
+ "bc1qqleages8702xvg9qcyu02yclst24xurdrynvxq",
+ "bc1qsleagehks96u7jmqrzcf0fw80ea5g57qm3m84c"
+ ),
+ subject = "0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00",
+ amountRaw = Amount(CURRENCY_BTC, 0, 14000000),
+ amountEffective = Amount(CURRENCY_BTC, 0, 14000000),
+ withdrawalAccount = WithdrawalExchangeAccountDetails(
+ paytoUri = "https://taler.net/btc",
+ transferAmount = Amount("BTC", 0, 14000000),
+ status = Ok,
+ currencySpecification = CurrencySpecification(
+ "Bitcoin",
+ numFractionalInputDigits = 2,
+ numFractionalNormalDigits = 2,
+ numFractionalTrailingZeroDigits = 2,
+ altUnitNames = emptyMap(),
+ ),
+ ),
+ )
+ ),
+ ),
+ spec = null,
+ bankAppClick = {},
+ shareClick = {},
+ )
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferBitcoin.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferBitcoin.kt
new file mode 100644
index 0000000..c21ca7e
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferBitcoin.kt
@@ -0,0 +1,112 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.withdraw.manual
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.wallet.R
+import net.taler.wallet.compose.CopyToClipboardButton
+import net.taler.wallet.withdraw.TransferData
+
+@Composable
+fun TransferBitcoin(
+ transfer: TransferData.Bitcoin,
+ transactionAmountRaw: Amount,
+ transactionAmountEffective: Amount,
+) {
+ Column(
+ modifier = Modifier.padding(all = 16.dp),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.withdraw_manual_bitcoin_intro),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier
+ .padding(vertical = 8.dp)
+ )
+
+ BitcoinSegwitAddresses(
+ amount = transfer.amountRaw,
+ address = transfer.account,
+ segwitAddresses = transfer.segwitAddresses,
+ )
+
+ transfer.withdrawalAccount.transferAmount?.let { amount ->
+ WithdrawalAmountTransfer(
+ amountRaw = transactionAmountRaw,
+ amountEffective = transactionAmountEffective,
+ conversionAmountRaw = amount.withSpec(
+ transfer.withdrawalAccount.currencySpecification,
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+fun BitcoinSegwitAddresses(amount: Amount, address: String, segwitAddresses: List<String>) {
+ Column {
+ val allSegwitAddresses = listOf(address) + segwitAddresses
+ for (segwitAddress in allSegwitAddresses) {
+ Row(modifier = Modifier.padding(vertical = 8.dp)) {
+ Column(modifier = Modifier.weight(0.3f)) {
+ Text(
+ text = segwitAddress,
+ fontWeight = FontWeight.Normal,
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Text(
+ text = if (segwitAddress == address)
+ amount.withCurrency("BTC").toString()
+ else SEGWIT_MIN.toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ }
+
+ CopyToClipboardButton(
+ modifier = Modifier
+ .padding(top = 16.dp, start = 6.dp, end = 6.dp)
+ .align(CenterHorizontally),
+ label = "Bitcoin",
+ content = getCopyText(amount, address, segwitAddresses),
+ )
+ }
+}
+
+private val SEGWIT_MIN = Amount("BTC", 0, 294)
+
+private fun getCopyText(amount: Amount, addr: String, segwitAddresses: List<String>): String {
+ val sr = segwitAddresses.joinToString(separator = "\n") { s ->
+ "\n$s ${SEGWIT_MIN}\n"
+ }
+ return "$addr ${amount.withCurrency("BTC")}\n$sr"
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferIBAN.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferIBAN.kt
new file mode 100644
index 0000000..1698530
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferIBAN.kt
@@ -0,0 +1,93 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.withdraw.manual
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.wallet.R
+import net.taler.wallet.cleanExchange
+import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.withdraw.TransferData
+
+@Composable
+fun TransferIBAN(
+ transfer: TransferData.IBAN,
+ exchangeBaseUrl: String,
+ transactionAmountRaw: Amount,
+ transactionAmountEffective: Amount,
+) {
+ val transferAmount = transfer
+ .withdrawalAccount
+ .transferAmount
+ ?.withSpec(transfer.withdrawalAccount.currencySpecification)
+ ?: transfer.amountRaw
+
+ Column(
+ modifier = Modifier.padding(all = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(
+ R.string.withdraw_manual_ready_intro,
+ transferAmount),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier
+ .padding(vertical = 8.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.withdraw_manual_ready_warning),
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorResource(R.color.notice_text),
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(all = 8.dp)
+ .background(colorResource(R.color.notice_background))
+ .border(BorderStroke(2.dp, colorResource(R.color.notice_border)))
+ .padding(all = 16.dp)
+ )
+
+ DetailRow(stringResource(R.string.withdraw_manual_ready_subject), transfer.subject)
+ transfer.receiverName?.let {
+ DetailRow(stringResource(R.string.withdraw_manual_ready_receiver), it)
+ }
+ DetailRow(stringResource(R.string.withdraw_manual_ready_iban), transfer.iban)
+
+ TransactionInfoComposable(
+ label = stringResource(R.string.withdraw_exchange),
+ info = cleanExchange(exchangeBaseUrl),
+ )
+
+ WithdrawalAmountTransfer(
+ amountRaw = transactionAmountRaw,
+ amountEffective = transactionAmountEffective,
+ conversionAmountRaw = transferAmount,
+ )
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferTaler.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferTaler.kt
new file mode 100644
index 0000000..089d0de
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/withdraw/manual/TransferTaler.kt
@@ -0,0 +1,93 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.withdraw.manual
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import net.taler.common.Amount
+import net.taler.wallet.R
+import net.taler.wallet.cleanExchange
+import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.withdraw.TransferData
+
+@Composable
+fun TransferTaler(
+ transfer: TransferData.Taler,
+ exchangeBaseUrl: String,
+ transactionAmountRaw: Amount,
+ transactionAmountEffective: Amount,
+) {
+ val transferAmount = transfer
+ .withdrawalAccount
+ .transferAmount
+ ?.withSpec(transfer.withdrawalAccount.currencySpecification)
+ ?: transfer.amountRaw
+
+ Column(
+ modifier = Modifier.padding(all = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(
+ R.string.withdraw_manual_ready_intro,
+ transferAmount),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier
+ .padding(vertical = 8.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.withdraw_manual_ready_warning),
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorResource(R.color.notice_text),
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(all = 8.dp)
+ .background(colorResource(R.color.notice_background))
+ .border(BorderStroke(2.dp, colorResource(R.color.notice_border)))
+ .padding(all = 16.dp)
+ )
+
+ DetailRow(stringResource(R.string.withdraw_manual_ready_subject), transfer.subject)
+ transfer.receiverName?.let {
+ DetailRow(stringResource(R.string.withdraw_manual_ready_receiver), it)
+ }
+ DetailRow(stringResource(R.string.withdraw_manual_ready_account), transfer.account)
+
+ TransactionInfoComposable(
+ label = stringResource(R.string.withdraw_exchange),
+ info = cleanExchange(exchangeBaseUrl),
+ )
+
+ WithdrawalAmountTransfer(
+ amountRaw = transactionAmountRaw,
+ amountEffective = transactionAmountEffective,
+ conversionAmountRaw = transferAmount,
+ )
+ }
+} \ No newline at end of file