diff options
Diffstat (limited to 'wallet/src/main/java/net')
103 files changed, 5206 insertions, 2906 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..6b8db78 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt @@ -0,0 +1,272 @@ +/* + * 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 { + 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 bf95475..00fd2d3 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,11 +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 { @@ -99,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) @@ -109,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") @@ -139,6 +125,14 @@ 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.devMode.observe(this) { + invalidateMenu() + } } @Deprecated("Deprecated in Java") @@ -154,156 +148,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) - 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!") - nav.navigate(R.id.action_global_promptPayment) - model.paymentManager.preparePay(u2) - } - action.startsWith("tip/", ignoreCase = true) -> { - Log.v(TAG, "navigating!") - nav.navigate(R.id.action_global_promptTip) - model.tipManager.prepareTip(u2) - } - 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(u2) - } - action.startsWith("refund/", ignoreCase = true) -> { - model.showProgressBar.value = true - model.refundManager.refund(u2).observe(this, Observer(::onRefundResponse)) - } - action.startsWith("pay-pull/", ignoreCase = true) -> { - nav.navigate(R.id.action_global_prompt_pull_payment) - model.peerManager.preparePeerPullDebit(u2) - } - action.startsWith("pay-push/", ignoreCase = true) -> { - nav.navigate(R.id.action_global_prompt_push_payment) - model.peerManager.preparePeerPushCredit(u2) - } - else -> { - showError(R.string.error_unsupported_uri, "From: $from\nURI: $u2") - } - } - } + 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() { 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 2ad6f6b..82eb8d7 100644 --- a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt +++ b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -22,127 +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.Dispatchers -import kotlinx.coroutines.Job +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.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.backend.WalletCoreVersion -import net.taler.wallet.balances.BalanceItem -import net.taler.wallet.balances.BalanceResponse +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 const val TAG = "taler-wallet" +const val OBSERVABILITY_LIMIT = 100 private val transactionNotifications = listOf( - "proposal-accepted", - "refresh-revealed", - "withdraw-group-finished" + "transaction-state-transition", +) + +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, this, this) + @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, viewModelScope) 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, exchangeManager, viewModelScope) - val settingsManager: SettingsManager = SettingsManager(app.applicationContext, 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 override fun onVersionReceived(versionInfo: WalletCoreVersion) { + walletVersion = versionInfo.implementationSemver + walletVersionHash = versionInfo.implementationGitHash exchangeVersion = versionInfo.exchange merchantVersion = versionInfo.merchant } override fun onNotificationReceived(payload: NotificationPayload) { if (payload.type == "waiting-for-retry") return // ignore ping) - Log.i(TAG, "Received notification from wallet-core: $payload") - loadBalances() + 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() + } + + 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() } - // refresh pending ops and history with each notification - if (devMode.value == true) { - pendingOperationsManager.getPending() - } - } - - @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") - } - response.onSuccess { - mBalances.value = it.balances - } } /** - * 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 { @@ -157,7 +176,7 @@ class MainViewModel( @UiThread fun hasSufficientBalance(amount: Amount): Boolean { - balances.value?.forEach { balanceItem -> + balanceManager.balances.value?.forEach { balanceItem -> if (balanceItem.currency == amount.currency) { return balanceItem.available >= amount } @@ -167,11 +186,8 @@ class MainViewModel( @UiThread fun dangerouslyReset() { - viewModelScope.launch { - api.sendRequest("reset") - } withdrawManager.testWithdrawalStatus.value = null - mBalances.value = emptyList() + balanceManager.resetBalances() } fun startTunnel() { @@ -197,13 +213,30 @@ class MainViewModel( 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 runIntegrationTest() { viewModelScope.launch { - api.request<Unit>("runIntegrationTest") { + api.request<Unit>("runIntegrationTestV2") { put("amountToWithdraw", "KUDOS:42") put("amountToSpend", "KUDOS:23") - put("bankBaseUrl", "https://bank.demo.taler.net/") - put("bankAccessApiBaseUrl", "https://bank.demo.taler.net/demobanks/default/access-api/") + 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") @@ -211,6 +244,14 @@ class MainViewModel( } } + 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 0e362ac..25d35ec 100644 --- a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt @@ -29,12 +29,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -46,7 +43,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType.Companion.Decimal import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf @@ -55,14 +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?, @@ -71,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, ) @@ -81,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) { @@ -99,11 +101,11 @@ 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) { @@ -113,10 +115,10 @@ class ReceiveFundsFragment : Fragment() { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReceiveFundsIntro( currency: String, + spec: CurrencySpecification?, onManualWithdraw: (Amount) -> Unit, onPeerPull: (Amount) -> Unit, ) { @@ -126,39 +128,32 @@ 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 = MaterialTheme.colorScheme.error, - ) - } 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.titleLarge, ) @@ -176,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)) @@ -187,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) }, ) { @@ -201,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 c2680d5..ca72a64 100644 --- a/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt @@ -27,12 +27,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -44,7 +41,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf @@ -52,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,7 +79,7 @@ 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)) } private fun onDeposit(amount: Amount) { @@ -94,10 +94,10 @@ class SendFundsFragment : Fragment() { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SendFundsIntro( currency: String, + spec: CurrencySpecification?, hasSufficientBalance: (Amount) -> Boolean, onDeposit: (Amount) -> Unit, onPeerPush: (Amount) -> Unit, @@ -108,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( @@ -116,38 +116,29 @@ private fun SendFundsIntro( modifier = Modifier .padding(16.dp), ) { - OutlinedTextField( + AmountInputField( modifier = Modifier .weight(1f) .padding(end = 16.dp), value = text, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Decimal), onValueChange = { input -> isError = false insufficientBalance = false - val filtered = input.filter { it.isDigit() || it == '.' } - if (filtered.endsWith('.') || isValidAmountStr(filtered)) text = filtered + text = input }, - isError = isError || insufficientBalance, - label = { - if (isError) { - Text( - stringResource(R.string.receive_amount_invalid), - color = MaterialTheme.colorScheme.error, - ) - } else if (insufficientBalance) { - Text( - stringResource(R.string.payment_balance_insufficient), - color = MaterialTheme.colorScheme.error, - ) - } else { - Text(stringResource(R.string.send_amount)) + label = { Text(stringResource(R.string.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.titleLarge, ) @@ -160,7 +151,7 @@ private fun SendFundsIntro( 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) } @@ -200,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 435aa96..5c4fedc 100644 --- a/wallet/src/main/java/net/taler/wallet/Utils.kt +++ b/wallet/src/main/java/net/taler/wallet/Utils.kt @@ -32,12 +32,15 @@ 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" @@ -110,18 +113,35 @@ fun Context.getAttrColor(attr: Int): Int { return value.data } -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 launchInAppBrowser(context: Context, url: String) { val builder = CustomTabsIntent.Builder() 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/backend/ApiResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt index 46eb2f0..def4668 100644 --- a/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt +++ b/wallet/src/main/java/net/taler/wallet/backend/ApiResponse.kt @@ -19,6 +19,7 @@ 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 { @@ -35,6 +36,7 @@ sealed class ApiMessage { data class NotificationPayload( val type: String, val id: String? = null, + val event: ObservabilityEvent? = null, ) @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt b/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt index ae338e8..9292ef5 100644 --- a/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt +++ b/wallet/src/main/java/net/taler/wallet/backend/BackendManager.kt @@ -17,11 +17,11 @@ package net.taler.wallet.backend import android.util.Log -import kotlinx.serialization.decodeFromString 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 @@ -39,14 +39,20 @@ class 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) } diff --git a/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt index 076af87..7fe1a6b 100644 --- a/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt +++ b/wallet/src/main/java/net/taler/wallet/backend/InitResponse.kt @@ -17,22 +17,68 @@ 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 hash: String? = null, + val implementationSemver: String, + val implementationGitHash: String, val version: String, val exchange: String, val merchant: String, - val bank: String, + val bankIntegrationApiRange: String, + val bankConversionApiRange: String, + val corebankApiRange: String, val devMode: Boolean, ) 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 06b8cee..fba9885 100644 --- a/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt +++ b/wallet/src/main/java/net/taler/wallet/backend/WalletBackendApi.kt @@ -23,21 +23,24 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.KSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import net.taler.wallet.backend.TalerErrorCode.NONE import org.json.JSONObject +import java.io.File -const val WALLET_DB = "talerwalletdb-v30.json" +private const val WALLET_DB = "talerwalletdb.sqlite3" @OptIn(DelicateCoroutinesApi::class) class WalletBackendApi( - app: Application, + private val app: Application, + private val initialConfig: WalletRunConfig, private val versionReceiver: VersionReceiver, notificationReceiver: NotificationReceiver, ) { private val backendManager = BackendManager(notificationReceiver) - private val dbPath = "${app.filesDir}/${WALLET_DB}" init { GlobalScope.launch(Dispatchers.IO) { @@ -47,16 +50,31 @@ class WalletBackendApi( } 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}" + } + request("init", InitResponse.serializer()) { - put("persistentStoragePath", dbPath) + 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") } } + suspend fun setWalletConfig(config: WalletRunConfig): WalletResponse<InitResponse> { + return request("initWallet", InitResponse.serializer()) { + put("config", JSONObject(BackendManager.json.encodeToString(config))) + } + } + suspend fun sendRequest(operation: String, args: JSONObject? = null): ApiResponse { return backendManager.send(operation, args) } @@ -75,6 +93,30 @@ class WalletBackendApi( } ?: Unit as T WalletResponse.Success(t) } + + 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) + } + } + + // 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) + } + is ApiResponse.Error -> { val error: TalerErrorInfo = json.decodeFromJsonElement(response.error) WalletResponse.Error(error) 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 37bf91e..3946457 100644 --- a/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt +++ b/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @Serializable @@ -75,8 +76,18 @@ data class TalerErrorInfo( val userFacingMsg: String get() { return StringBuilder().apply { - hint?.let { append(it) } - message?.let { append(" ").append(it) } + // 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() } 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..a524d1b --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt @@ -0,0 +1,226 @@ +/* + * 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, +) { + 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, + 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/compose/LoadingScreen.kt b/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt new file mode 100644 index 0000000..6412d63 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt @@ -0,0 +1,34 @@ +/* + * 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.compose + +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 + +@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 index c9d2fc5..47401cf 100644 --- a/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt +++ b/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt @@ -20,14 +20,12 @@ 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.ExperimentalMaterial3Api 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 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun NumericInputField( modifier: Modifier = Modifier, @@ -41,6 +39,7 @@ fun NumericInputField( OutlinedTextField( modifier = modifier, value = value.toString(), + singleLine = true, readOnly = readOnly, onValueChange = { val dd = it.toLongOrNull() ?: 0 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 2d7ffa1..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,21 +25,21 @@ 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.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.material.icons.Icons -import androidx.compose.material.icons.filled.ContentCopy 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.asImageBitmap @@ -136,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.bodyLarge, - ) - } + 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 index 454bbfa..c47f55d 100644 --- a/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt +++ b/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt @@ -16,7 +16,6 @@ package net.taler.wallet.compose -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChipDefaults @@ -24,7 +23,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -@OptIn(ExperimentalMaterial3Api::class) @Composable fun <T> SelectionChip( label: @Composable () -> Unit, 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 ebf2a2f..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.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.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Share 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.bodyLarge, - ) - } + 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/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 91f7ad5..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,10 @@ 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, bic) + makeIbanDeposit(amount, receiverName, iban) } else { // validate IBAN first mDepositState.value = DepositState.CheckingFees @@ -58,11 +58,11 @@ class DepositManager( put("iban", iban) }.onError { Log.e(TAG, "Error validateIban $it") - mDepositState.value = DepositState.Error(it.userFacingMsg) + mDepositState.value = DepositState.Error(it) }.onSuccess { response -> if (response.valid) { // only prepare/make deposit, if IBAN is valid - makeIbanDeposit(amount, receiverName, iban, bic) + makeIbanDeposit(amount, receiverName, iban) } else { mDepositState.value = DepositState.IbanInvalid } @@ -72,10 +72,10 @@ class DepositManager( } @UiThread - private fun makeIbanDeposit(amount: Amount, receiverName: String, iban: String, bic: String) { + 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 @@ -112,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, @@ -138,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 } 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 a019757..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 { @@ -43,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 e022ed3..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,9 +22,8 @@ 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.Button -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -51,7 +50,6 @@ import net.taler.wallet.R import net.taler.wallet.transactions.AmountType import net.taler.wallet.transactions.TransactionAmountComposable -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MakeBitcoinDepositComposable( state: DepositState, @@ -73,6 +71,7 @@ fun MakeBitcoinDepositComposable( .padding(16.dp) .focusRequester(focusRequester), value = address, + singleLine = true, enabled = !state.showFees, onValueChange = { input -> address = input @@ -92,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, @@ -105,14 +104,16 @@ fun MakeBitcoinDepositComposable( ) { val totalAmount = state.totalDepositCost ?: amount val effectiveAmount = state.effectiveDepositAmount ?: Amount.zero(amount.currency) - val fee = totalAmount - effectiveAmount - TransactionAmountComposable( - label = stringResource(id = R.string.withdraw_fees), - amount = fee, - amountType = AmountType.Negative, - ) + 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.send_amount), + label = stringResource(id = R.string.amount_send), amount = totalAmount, amountType = AmountType.Positive, ) @@ -123,7 +124,7 @@ fun MakeBitcoinDepositComposable( modifier = Modifier.padding(16.dp), fontSize = 18.sp, color = MaterialTheme.colorScheme.error, - text = (state as? DepositState.Error)?.msg ?: "", + text = (state as? DepositState.Error)?.error?.userFacingMsg ?: "", ) } val focusManager = LocalFocusManager.current @@ -149,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 3c93ed7..2f9fd88 100644 --- a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt @@ -23,7 +23,6 @@ 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.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -41,22 +40,23 @@ 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 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MakeDepositComposable( state: DepositState, amount: Amount, presetName: String? = null, presetIban: String? = null, - onMakeDeposit: (Amount, String, String, String) -> Unit, + onMakeDeposit: (Amount, String, String) -> Unit, ) { val scrollState = rememberScrollState() Column( @@ -67,18 +67,18 @@ fun MakeDepositComposable( ) { var name by rememberSaveable { mutableStateOf(presetName ?: "") } var iban by rememberSaveable { mutableStateOf(presetIban ?: "") } - var bic by rememberSaveable { mutableStateOf("") } - var bicInvalid by rememberSaveable { mutableStateOf(false) } 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( @@ -95,8 +95,10 @@ fun MakeDepositComposable( 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.uppercase() @@ -120,46 +122,10 @@ fun MakeDepositComposable( ) } ) - OutlinedTextField( - modifier = Modifier - .padding(16.dp), - value = bic, - enabled = !state.showFees, - onValueChange = { input -> - bicInvalid = false - bic = input - }, - isError = bicInvalid, - supportingText = { - if (bicInvalid) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.send_deposit_bic_error), - color = MaterialTheme.colorScheme.error - ) - } - }, - 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( @@ -168,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 = if (fee.isZero()) colorResource(R.color.green) else MaterialTheme.colorScheme.error, - 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, ) } } @@ -200,7 +156,7 @@ fun MakeDepositComposable( modifier = Modifier.padding(16.dp), fontSize = 18.sp, color = MaterialTheme.colorScheme.error, - text = (state as? DepositState.Error)?.msg ?: "", + text = (state as? DepositState.Error)?.error?.userFacingMsg ?: "", ) } val focusManager = LocalFocusManager.current @@ -209,11 +165,7 @@ fun MakeDepositComposable( enabled = iban.isNotBlank(), onClick = { focusManager.clearFocus() - if (isValidBic(bic)) { - onMakeDeposit(amount, name, iban, bic) - } else { - bicInvalid = true - } + onMakeDeposit(amount, name, iban) }, ) { Text( @@ -226,25 +178,17 @@ fun MakeDepositComposable( } } -private val bicRegex = Regex("[a-zA-Z\\d]{8,11}") - -/** - * performs some minimal verification, nothing perfect. - * Allows for empty string. - */ -private fun isValidBic(bic: String): Boolean = bic.isEmpty() || bicRegex.matches(bic) - @Preview @Composable 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 c8b5b6e..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,12 +30,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -49,13 +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.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() { @@ -87,7 +86,7 @@ class PayToUriFragment : Fragment() { text = stringResource(id = R.string.payment_balance_insufficient), 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) @@ -115,7 +114,6 @@ class PayToUriFragment : Fragment() { } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PayToComposable( currencies: List<String>, @@ -131,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 = MaterialTheme.colorScheme.error) - } - } ) CurrencyDropdown( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Center), currencies = currencies, onCurrencyChanged = { c -> currency = c }, ) @@ -163,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), @@ -184,22 +179,23 @@ private fun PayToComposable( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun CurrencyDropdown( currencies: List<String>, onCurrencyChanged: (String) -> Unit, + modifier: Modifier = Modifier, + initialCurrency: String? = null, + readOnly: Boolean = false, ) { - var selectedIndex by remember { mutableStateOf(0) } + val initialIndex = currencies.indexOf(initialCurrency).let { if (it < 0) 0 else it } + var selectedIndex by remember { mutableStateOf(initialIndex) } var expanded by remember { mutableStateOf(false) } Box( - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center), + modifier = modifier, ) { OutlinedTextField( modifier = Modifier - .clickable(onClick = { expanded = true }), + .clickable(onClick = { if (!readOnly) expanded = true }), value = currencies[selectedIndex], onValueChange = { }, readOnly = true, diff --git a/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt index 3d59b35..11264a1 100644 --- a/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt @@ -32,20 +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.ExtendedStatus.Pending +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, devMode: Boolean?, onDelete: () -> Unit) { +fun TransactionDepositComposable( + t: TransactionDeposit, + devMode: Boolean, + spec: CurrencySpecification?, + onTransition: (t: TransactionAction) -> Unit, +) { val scrollState = rememberScrollState() Column( modifier = Modifier @@ -59,26 +70,30 @@ fun TransactionDepositComposable(t: TransactionDeposit, devMode: Boolean?, onDel text = t.timestamp.ms.toAbsoluteTime(context).toString(), style = MaterialTheme.typography.bodyLarge, ) + TransactionAmountComposable( - label = stringResource(id = R.string.transaction_paid), - amount = t.amountEffective, - amountType = AmountType.Negative, - ) - 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) - if (devMode == true && t.error != null) { + + 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) } } @@ -90,14 +105,15 @@ fun TransactionDepositComposablePreview() { val t = TransactionDeposit( transactionId = "transactionId", timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - extendedStatus = Pending, + 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, true) {} + 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/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/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt index 21e31f4..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 + 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 4a57068..fa357b5 100644 --- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt +++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -21,6 +21,7 @@ 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 @@ -28,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 @@ -35,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, @@ -46,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 @@ -56,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 @@ -71,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") @@ -81,6 +98,38 @@ 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 { emit(findExchange(currency)) } @@ -98,4 +147,49 @@ class ExchangeManager( 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..9107dc9 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -0,0 +1,184 @@ +/* + * 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 + +sealed class AmountFieldStatus { + object FixedAmount : AmountFieldStatus() + class Default( + val amountStr: String? = null, + val currency: String? = null, + ) : AmountFieldStatus() + + object Invalid : AmountFieldStatus() +} + +@Composable +fun PayTemplateComposable( + defaultSummary: String?, + amountStatus: AmountFieldStatus, + currencies: List<String>, + payStatus: PayStatus, + onCreateAmount: (String, String) -> AmountResult, + onSubmit: (summary: String?, amount: Amount?) -> Unit, + onError: (resId: Int) -> Unit, +) { + // If wallet is empty, there's no way the user can pay something + if (amountStatus is AmountFieldStatus.Invalid) { + PayTemplateError(stringResource(R.string.amount_invalid)) + } else if (currencies.isEmpty()) { + PayTemplateError(stringResource(R.string.payment_balance_insufficient)) + } else when (val p = payStatus) { + is PayStatus.None -> PayTemplateOrderComposable( + currencies = currencies, + defaultSummary = defaultSummary, + amountStatus = amountStatus, + onCreateAmount = onCreateAmount, + onError = onError, + onSubmit = onSubmit, + ) + + is PayStatus.Loading -> PayTemplateLoading() + is PayStatus.AlreadyPaid -> PayTemplateError(stringResource(R.string.payment_already_paid)) + is PayStatus.InsufficientBalance -> PayTemplateError(stringResource(R.string.payment_balance_insufficient)) + is PayStatus.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( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + payStatus = PayStatus.Loading, + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { _ -> }, + ) + } +} + +@Preview +@Composable +fun PayTemplateInsufficientBalancePreview() { + TalerSurface { + PayTemplateComposable( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + payStatus = PayStatus.InsufficientBalance( + ContractTerms( + "test", + amount = Amount.zero("TESTKUDOS"), + products = emptyList() + ), Amount.zero("TESTKUDOS") + ), + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { _ -> }, + ) + } +} + +@Preview(widthDp = 300) +@Composable +fun PayTemplateAlreadyPaidPreview() { + TalerSurface { + PayTemplateComposable( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + 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( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + payStatus = PayStatus.None, + currencies = emptyList(), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { _ -> }, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt new file mode 100644 index 0000000..4eb2c11 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt @@ -0,0 +1,115 @@ +/* + * 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.Amount +import net.taler.common.showError +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.showError + +class PayTemplateFragment : Fragment() { + + private val model: MainViewModel by activityViewModels() + private lateinit var uriString: String + private lateinit var uri: Uri + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + uriString = arguments?.getString("uri") ?: error("no amount passed") + uri = Uri.parse(uriString) + + val defaultSummary = uri.getQueryParameter("summary") + val defaultAmount = uri.getQueryParameter("amount") + val amountFieldStatus = getAmountFieldStatus(defaultAmount) + + val payStatusFlow = model.paymentManager.payStatus.asFlow() + + return ComposeView(requireContext()).apply { + setContent { + val payStatus = payStatusFlow.collectAsStateLifecycleAware(initial = PayStatus.None) + TalerSurface { + PayTemplateComposable( + currencies = model.getCurrencies(), + defaultSummary = defaultSummary, + amountStatus = amountFieldStatus, + payStatus = payStatus.value, + onCreateAmount = model::createAmount, + onSubmit = this@PayTemplateFragment::createOrder, + onError = { this@PayTemplateFragment.showError(it) }, + ) + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (uri.queryParameterNames?.isEmpty() == true) { + createOrder(null, null) + } + + model.paymentManager.payStatus.observe(viewLifecycleOwner) { payStatus -> + when (payStatus) { + is PayStatus.Prepared -> { + findNavController().navigate(R.id.action_promptPayTemplate_to_promptPayment) + } + + is PayStatus.Pending -> if (payStatus.error != null && model.devMode.value == true) { + showError(payStatus.error) + } + + else -> {} + } + } + } + + private fun getAmountFieldStatus(defaultAmount: String?): AmountFieldStatus { + return if (defaultAmount == null) { + AmountFieldStatus.FixedAmount + } else if (defaultAmount.isBlank()) { + AmountFieldStatus.Default() + } else { + val parts = defaultAmount.split(":") + when (parts.size) { + 0 -> AmountFieldStatus.Default() + 1 -> AmountFieldStatus.Default(currency = parts[0]) + 2 -> AmountFieldStatus.Default(parts[1], parts[0]) + else -> AmountFieldStatus.Invalid + } + } + } + + private fun createOrder(summary: String?, amount: Amount?) { + model.paymentManager.preparePayForTemplate(uriString, summary, amount) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt new file mode 100644 index 0000000..9647c42 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt @@ -0,0 +1,177 @@ +/* + * 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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.wallet.AmountResult +import net.taler.wallet.R +import net.taler.wallet.compose.AmountInputField +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.deposit.CurrencyDropdown + +@Composable +fun PayTemplateOrderComposable( + currencies: List<String>, // assumed to have size > 0 + defaultSummary: String? = null, + amountStatus: AmountFieldStatus, + onCreateAmount: (String, String) -> AmountResult, + onError: (msgRes: Int) -> Unit, + onSubmit: (summary: String?, amount: Amount?) -> Unit, +) { + val amountDefault = amountStatus as? AmountFieldStatus.Default + + var summary by remember { mutableStateOf(defaultSummary) } + var currency by remember { mutableStateOf(amountDefault?.currency ?: currencies[0]) } + var amount by remember { mutableStateOf(amountDefault?.amountStr ?: "0") } + + Column(horizontalAlignment = End) { + if (defaultSummary != null) OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + value = summary ?: "", + isError = summary.isNullOrBlank(), + onValueChange = { summary = it }, + singleLine = true, + label = { Text(stringResource(R.string.withdraw_manual_ready_subject)) }, + ) + if (amountDefault != null) AmountField( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + amount = amount, + currency = currency, + currencies = currencies, + fixedCurrency = (amountStatus as? AmountFieldStatus.Default)?.currency != null, + onAmountChosen = { a, c -> + amount = a + currency = c + }, + ) + Button( + modifier = Modifier.padding(16.dp), + enabled = defaultSummary == null || !summary.isNullOrBlank(), + onClick = { + when (val res = onCreateAmount(amount, currency)) { + is AmountResult.InsufficientBalance -> onError(R.string.payment_balance_insufficient) + is AmountResult.InvalidAmount -> onError(R.string.amount_invalid) + is AmountResult.Success -> onSubmit(summary, res.amount) + } + }, + ) { + Text(stringResource(R.string.payment_create_order)) + } + } +} + +@Composable +private fun AmountField( + modifier: Modifier = Modifier, + currencies: List<String>, + fixedCurrency: Boolean, + amount: String, + currency: String, + onAmountChosen: (amount: String, currency: String) -> Unit, +) { + Row( + modifier = modifier, + ) { + AmountInputField( + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + value = amount, + onValueChange = { onAmountChosen(it, currency) }, + label = { Text(stringResource(R.string.amount_send)) } + ) + CurrencyDropdown( + modifier = Modifier.weight(1f), + initialCurrency = currency, + currencies = currencies, + onCurrencyChanged = { onAmountChosen(amount, it) }, + readOnly = fixedCurrency, + ) + } +} + +@Preview +@Composable +fun PayTemplateDefaultPreview() { + TalerSurface { + PayTemplateOrderComposable( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { }, + ) + } +} + +@Preview +@Composable +fun PayTemplateFixedAmountPreview() { + TalerSurface { + PayTemplateOrderComposable( + defaultSummary = "default summary", + amountStatus = AmountFieldStatus.FixedAmount, + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { }, + ) + } +} + +@Preview +@Composable +fun PayTemplateBlankSubjectPreview() { + TalerSurface { + PayTemplateOrderComposable( + defaultSummary = "", + amountStatus = AmountFieldStatus.FixedAmount, + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { }, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt index 53cb259..35cd9e6 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -32,6 +32,7 @@ import net.taler.wallet.payment.PayStatus.InsufficientBalance import net.taler.wallet.payment.PreparePayResponse.AlreadyConfirmedResponse import net.taler.wallet.payment.PreparePayResponse.InsufficientBalanceResponse import net.taler.wallet.payment.PreparePayResponse.PaymentPossibleResponse +import org.json.JSONObject val REGEX_PRODUCT_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$") @@ -40,7 +41,7 @@ sealed class PayStatus { object Loading : PayStatus() data class Prepared( val contractTerms: ContractTerms, - val proposalId: String, + val transactionId: String, val amountRaw: Amount, val amountEffective: Amount, ) : PayStatus() @@ -50,10 +51,18 @@ sealed class PayStatus { 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() } class PaymentManager( @@ -75,42 +84,57 @@ 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)) - } - } - - @UiThread - fun abortPay() { - val ps = payStatus.value - if (ps is PayStatus.Prepared) { - abortProposal(ps.proposalId) + }.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, + ) + }) } - resetPayStatus() } - internal fun abortProposal(proposalId: String) = scope.launch { - Log.i(TAG, "aborting proposal") - api.request<Unit>("abortProposal") { - put("proposalId", proposalId) + fun preparePayForTemplate(url: String, summary: String?, amount: Amount?) = scope.launch { + mPayStatus.value = PayStatus.Loading + api.request("preparePayForTemplate", PreparePayResponse.serializer()) { + put("talerPayTemplateUri", url) + put("templateParams", JSONObject().apply { + summary?.let { put("summary", it) } + amount?.let { put("amount", it.toJSONString()) } + }) }.onError { - 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 +145,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..31c26a0 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) + } + } } } @@ -91,8 +106,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 +122,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 +174,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 index c760bb4..beb37d9 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -32,6 +32,7 @@ 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 @@ -39,22 +40,27 @@ 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.DeleteTransactionComposable import net.taler.wallet.transactions.ErrorTransactionButton -import net.taler.wallet.transactions.ExtendedStatus -import net.taler.wallet.transactions.PaymentStatus +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, - onDelete: () -> Unit, + onTransition: (t: TransactionAction) -> Unit, ) { val scrollState = rememberScrollState() Column( @@ -69,25 +75,40 @@ fun TransactionPaymentComposable( text = t.timestamp.ms.toAbsoluteTime(context).toString(), style = MaterialTheme.typography.bodyLarge, ) - TransactionAmountComposable( - label = stringResource(id = R.string.transaction_paid), - amount = t.amountEffective, - amountType = AmountType.Negative, - ) + TransactionAmountComposable( label = stringResource(id = R.string.transaction_order_total), - amount = t.amountRaw, + 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.withdraw_fees), - amount = t.amountEffective - t.amountRaw, + 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 ?: "") } - DeleteTransactionComposable(onDelete) + + TransitionsComposable(t, devMode, onTransition) if (devMode && t.error != null) { ErrorTransactionButton(error = t.error) } @@ -133,7 +154,8 @@ fun TransactionPaymentComposablePreview() { val t = TransactionPayment( transactionId = "transactionId", timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - extendedStatus = ExtendedStatus.Pending, + txState = TransactionState(Pending), + txActions = listOf(Retry, Suspend, Abort), info = TransactionInfo( orderId = "123", merchant = ContractMerchant(name = "Taler"), @@ -142,12 +164,11 @@ fun TransactionPaymentComposablePreview() { fulfillmentUrl = "https://bank.demo.taler.net/", products = listOf(), ), - status = PaymentStatus.Paid, - amountRaw = Amount.fromDouble("TESTKUDOS", 42.1337), - amountEffective = Amount.fromDouble("TESTKUDOS", 42.23), + 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, onFulfill = {}) {} + TransactionPaymentComposable(t = t, devMode = true, spec = null, onFulfill = {}) {} } } 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 2e2ed8a..609629e 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt @@ -48,6 +48,8 @@ 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 @@ -132,7 +134,7 @@ fun ColumnScope.PeerPullTermsComposable( modifier = Modifier.align(End), ) { Text( - text = stringResource(id = R.string.payment_label_amount_total), + text = stringResource(id = R.string.amount_total_label), style = MaterialTheme.typography.bodyLarge, ) Text( @@ -143,22 +145,26 @@ fun ColumnScope.PeerPullTermsComposable( ) } // this gets used for credit and debit, so fee calculation differs - val fee = if (data.isCredit) { + val fee = if (data.isCredit && terms.amountRaw > terms.amountEffective) { terms.amountRaw - terms.amountEffective - } else { + } 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 = feeStr, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) } - val feeStr = if (data.isCredit) { - stringResource(R.string.amount_negative, fee) - } else { - stringResource(R.string.amount_positive, fee) - } - if (!fee.isZero()) Text( - modifier = Modifier.align(End), - text = feeStr, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - ) + if (terms is IncomingAccepting) { CircularProgressIndicator( modifier = Modifier @@ -187,11 +193,17 @@ 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, + text = message, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.error, ) @@ -212,11 +224,11 @@ fun PeerPullCheckingPreview() { fun PeerPullTermsPreview() { Surface { val terms = IncomingTerms( - amountRaw = Amount.fromDouble("TESTKUDOS", 42.23), - amountEffective = Amount.fromDouble("TESTKUDOS", 42.423), + 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", ) @@ -232,11 +244,11 @@ fun PeerPullTermsPreview() { fun PeerPullAcceptingPreview() { Surface { val terms = IncomingTerms( - amountRaw = Amount.fromDouble("TESTKUDOS", 42.23), - amountEffective = Amount.fromDouble("TESTKUDOS", 42.123), + 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", ) 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 3aa0963..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) + } } } } 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 736ccd5..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) + } } } } 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 c6c78f3..cd5b5dd 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt @@ -48,7 +48,7 @@ data class PreparePeerPullDebitResponse( val contractTerms: PeerContractTerms, val amountRaw: Amount, val amountEffective: Amount, - val peerPullPaymentIncomingId: String, + val transactionId: String, ) @Serializable @@ -56,5 +56,5 @@ data class PreparePeerPushCreditResponse( val contractTerms: PeerContractTerms, val amountRaw: Amount, val amountEffective: Amount, - val peerPushPaymentIncomingId: String, + val transactionId: String, ) diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt index f227dec..f3d569f 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -16,16 +16,18 @@ 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.ExperimentalMaterial3Api +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator 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 @@ -34,6 +36,7 @@ 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 @@ -43,16 +46,51 @@ 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 -@OptIn(ExperimentalMaterial3Api::class) +@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, @@ -69,6 +107,7 @@ fun OutgoingPullIntroComposable( ) { var subject by rememberSaveable { mutableStateOf("") } val focusRequester = remember { FocusRequester() } + OutlinedTextField( modifier = Modifier .fillMaxWidth() @@ -89,9 +128,11 @@ fun OutgoingPullIntroComposable( ) } ) + LaunchedEffect(Unit) { focusRequester.requestFocus() } + Text( modifier = Modifier .fillMaxWidth() @@ -100,29 +141,34 @@ fun OutgoingPullIntroComposable( 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) { + + if (state is OutgoingChecked && state.amountRaw > state.amountEffective) { val fee = state.amountRaw - state.amountEffective - if (!fee.isZero()) TransactionAmountComposable( - label = stringResource(id = R.string.withdraw_fees), - amount = fee, + 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( @@ -131,6 +177,7 @@ fun OutgoingPullIntroComposable( hours = hours, onOptionChange = { option = it } ) { hours = it } + Button( modifier = Modifier.padding(16.dp), enabled = subject.isNotBlank() && state is OutgoingChecked, @@ -148,27 +195,86 @@ fun OutgoingPullIntroComposable( } } +@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 PreviewReceiveFundsCheckingIntro() { - Surface { - OutgoingPullIntroComposable( - Amount.fromDouble("TESTKUDOS", 42.23), - if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking, - ) { _, _, _, _ -> } +fun PeerPullComposableCheckingPreview() { + TalerSurface { + OutgoingPullComposable( + amount = Amount.fromString("TESTKUDOS", "42.23"), + state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking, + onCreateInvoice = { _, _, _, _ -> }, + onClose = {}, + ) } } @Preview @Composable -fun PreviewReceiveFundsCheckedIntro() { - Surface { - val amountRaw = Amount.fromDouble("TESTKUDOS", 42.42) - val amountEffective = Amount.fromDouble("TESTKUDOS", 42.23) +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()) - OutgoingPullIntroComposable( - Amount.fromDouble("TESTKUDOS", 42.23), - OutgoingChecked(amountRaw, amountEffective, exchangeItem) - ) { _, _, _, _ -> } + 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 565aeb1..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,42 @@ class OutgoingPullFragment : 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) } + return ComposeView(requireContext()).apply { setContent { TalerSurface { - when (val state = peerManager.pullState.collectAsStateLifecycleAware().value) { - is OutgoingIntro, OutgoingChecking, is OutgoingChecked -> { - OutgoingPullIntroComposable( - amount = amount, - state = state, - onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice, - ) + 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) } - OutgoingCreating, is OutgoingResponse, is OutgoingError -> { - OutgoingPullResultComposable(state) { - 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) + } } } } 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 de62cda..0000000 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullResultComposable.kt +++ /dev/null @@ -1,150 +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.material3.Button -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.ui.Alignment.Companion.CenterHorizontally -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 kotlinx.serialization.json.JsonPrimitive -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 - -@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.titleLarge, - text = stringResource(id = R.string.receive_peer_invoice_instruction), - ) - when (state) { - OutgoingIntro, OutgoingChecking, is OutgoingChecked -> { - error("Result composable with ${state::class.simpleName}") - } - 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.bodyLarge, - 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 = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyLarge, - 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 = mapOf("foo" to JsonPrimitive("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/OutgoingPushIntroComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt index 0bf835c..7eba733 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushIntroComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -22,14 +22,13 @@ 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.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 @@ -43,11 +42,32 @@ 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 -@OptIn(ExperimentalMaterial3Api::class) +@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, @@ -68,11 +88,12 @@ fun OutgoingPushIntroComposable( softWrap = false, style = MaterialTheme.typography.titleLarge, ) - if (state is OutgoingChecked) { + + 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), + text = stringResource(id = R.string.payment_fee, fee.withSpec(amount.spec)), softWrap = false, color = MaterialTheme.colorScheme.error, ) @@ -100,9 +121,11 @@ fun OutgoingPushIntroComposable( ) } ) + LaunchedEffect(Unit) { focusRequester.requestFocus() } + Text( modifier = Modifier .fillMaxWidth() @@ -111,23 +134,22 @@ fun OutgoingPushIntroComposable( 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 { mutableStateOf(DEFAULT_EXPIRY.hours) } + 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 } - Text( - modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), - text = stringResource(R.string.send_peer_warning), - ) + Button( enabled = state is OutgoingChecked && subject.isNotBlank(), onClick = { onSend(amount, subject, hours) }, @@ -139,20 +161,58 @@ fun OutgoingPushIntroComposable( @Preview @Composable -fun PeerPushIntroComposableCheckingPreview() { - Surface { +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 - OutgoingPushIntroComposable(state, Amount.fromDouble("TESTKUDOS", 42.23)) { _, _, _ -> } + OutgoingPushComposable( + state = state, + amount = Amount.fromString("TESTKUDOS", "42.23"), + onSend = { _, _, _ -> }, + onClose = {}, + ) } } @Preview @Composable -fun PeerPushIntroComposableCheckedPreview() { - Surface { - val amountEffective = Amount.fromDouble("TESTKUDOS", 42.42) - val amountRaw = Amount.fromDouble("TESTKUDOS", 42.23) +fun PeerPushComposableCheckedPreview() { + TalerSurface { + val amountEffective = Amount.fromString("TESTKUDOS", "42.42") + val amountRaw = Amount.fromString("TESTKUDOS", "42.23") val state = OutgoingChecked(amountRaw, amountEffective) - OutgoingPushIntroComposable(state, 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 255aee5..01fb566 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt @@ -24,17 +24,24 @@ 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) { @@ -51,6 +58,8 @@ 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 @@ -59,22 +68,39 @@ class OutgoingPushFragment : Fragment() { return ComposeView(requireContext()).apply { setContent { TalerSurface { - when (val state = peerManager.pushState.collectAsStateLifecycleAware().value) { - is OutgoingIntro, OutgoingChecking, is OutgoingChecked -> { - backPressedCallback.isEnabled = false - OutgoingPushIntroComposable( - state = state, - amount = amount, - onSend = this@OutgoingPushFragment::onSend, - ) + 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) } - OutgoingCreating, is OutgoingResponse, is OutgoingError -> { - backPressedCallback.isEnabled = true - OutgoingPushResultComposable(state) { - findNavController().navigate(R.id.action_nav_peer_push_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 } } } 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 0a4ee70..0000000 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushResultComposable.kt +++ /dev/null @@ -1,150 +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.material3.Button -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.ui.Alignment.Companion.CenterHorizontally -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 kotlinx.serialization.json.JsonPrimitive -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 - -@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.titleLarge, - text = stringResource(id = R.string.send_peer_payment_instruction), - ) - when (state) { - OutgoingIntro, OutgoingChecking, is OutgoingChecked -> { - error("Result composable with ${state::class.simpleName}") - } - 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.bodyLarge, - 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 = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyLarge, - 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 = mapOf("foo" to JsonPrimitive("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 5673417..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,7 +16,6 @@ package net.taler.wallet.peer -import android.graphics.Bitmap import kotlinx.serialization.Serializable import net.taler.common.Amount import net.taler.wallet.backend.TalerErrorInfo @@ -32,8 +31,7 @@ data class OutgoingChecked( ) : OutgoingState() object OutgoingCreating : OutgoingState() data class OutgoingResponse( - val talerUri: String, - val qrCode: Bitmap, + val transactionId: String, ) : OutgoingState() data class OutgoingError( @@ -49,10 +47,7 @@ data class CheckPeerPullCreditResponse( @Serializable data class InitiatePeerPullPaymentResponse( - /** - * Taler URI for the other party to make the payment that was requested. - */ - val talerUri: String, + val transactionId: String, ) @Serializable @@ -62,7 +57,7 @@ data class CheckPeerPushDebitResponse( ) @Serializable -data class InitiatePeerPullCreditResponse( +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 bff55ff..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,7 +25,6 @@ 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 @@ -95,8 +94,7 @@ 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 initiatePeerPullCredit error result $error") _outgoingPullState.value = OutgoingError(error) @@ -130,7 +128,7 @@ class PeerManager( _outgoingPushState.value = OutgoingCreating scope.launch(Dispatchers.IO) { val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours)) - api.request("initiatePeerPushDebit", InitiatePeerPullCreditResponse.serializer()) { + api.request("initiatePeerPushDebit", InitiatePeerPushDebitResponse.serializer()) { put("amount", amount.toJSONString()) put("partialContractTerms", JSONObject().apply { put("amount", amount.toJSONString()) @@ -138,8 +136,7 @@ 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 initiatePeerPushDebit error result $error") _outgoingPushState.value = OutgoingError(error) @@ -161,7 +158,7 @@ class PeerManager( amountRaw = response.amountRaw, amountEffective = response.amountEffective, contractTerms = response.contractTerms, - id = response.peerPullPaymentIncomingId, + id = response.transactionId, ) }.onError { error -> Log.e(TAG, "got preparePeerPullDebit error result $error") @@ -174,7 +171,7 @@ class PeerManager( _incomingPullState.value = IncomingAccepting(terms) scope.launch(Dispatchers.IO) { api.request<Unit>("confirmPeerPullDebit") { - put("peerPullPaymentIncomingId", terms.id) + put("transactionId", terms.id) }.onSuccess { _incomingPullState.value = IncomingAccepted }.onError { error -> @@ -194,7 +191,7 @@ class PeerManager( amountRaw = response.amountRaw, amountEffective = response.amountEffective, contractTerms = response.contractTerms, - id = response.peerPushPaymentIncomingId, + id = response.transactionId, ) }.onError { error -> Log.e(TAG, "got preparePeerPushCredit error result $error") @@ -207,7 +204,7 @@ class PeerManager( _incomingPushState.value = IncomingAccepting(terms) scope.launch(Dispatchers.IO) { api.request<Unit>("confirmPeerPushCredit") { - put("peerPushPaymentIncomingId", terms.id) + put("transactionId", terms.id) }.onSuccess { _incomingPushState.value = IncomingAccepted }.onError { 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 d6b798c..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,78 +17,75 @@ package net.taler.wallet.peer import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -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.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.transactions.AmountType -import net.taler.wallet.transactions.ExtendedStatus.Pending 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.extendedStatus == Pending) { - QrCodeUriComposable( - talerUri = t.talerUri, - clipBoardLabel = "Invoice", - 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), - ) - } - } } @Preview @Composable -fun TransactionPeerPullCreditPreview() { +fun TransactionPeerPullCreditPreview(loading: Boolean = false) { val t = TransactionPeerPullCredit( transactionId = "transactionId", timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - extendedStatus = Pending, + 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", @@ -97,6 +94,12 @@ fun TransactionPeerPullCreditPreview() { error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED), ) Surface { - TransactionPeerComposable(t, true) {} + 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 1bbc223..b8966d4 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt @@ -21,38 +21,46 @@ 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.ExtendedStatus.Pending 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 ?: "", @@ -65,10 +73,11 @@ fun TransactionPeerPullDebitPreview() { val t = TransactionPeerPullDebit( transactionId = "transactionId", timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - extendedStatus = Pending, + 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", @@ -76,6 +85,6 @@ fun TransactionPeerPullDebitPreview() { error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED), ) Surface { - TransactionPeerComposable(t, true) {} + 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 d6f4cab..d407ff2 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt @@ -21,38 +21,46 @@ 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.ExtendedStatus.Pending 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 ?: "", @@ -65,10 +73,11 @@ fun TransactionPeerPushCreditPreview() { val t = TransactionPeerPushCredit( transactionId = "transactionId", timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - extendedStatus = Pending, + 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", @@ -76,6 +85,6 @@ fun TransactionPeerPushCreditPreview() { error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED), ) Surface { - TransactionPeerComposable(t, true) {} + 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 b8e8ff4..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,75 +18,120 @@ package net.taler.wallet.peer import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.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.ExtendedStatus.Pending 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.bodyLarge, - 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), - extendedStatus = Pending, + 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", @@ -94,7 +139,14 @@ fun TransactionPeerPushDebitPreview() { talerUri = "https://exchange.example.org/peer/pull/credit", error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED), ) - Surface { - TransactionPeerComposable(t, true) {} + + 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 f5079f6..0000000 --- a/wallet/src/main/java/net/taler/wallet/pending/PendingOperationsManager.kt +++ /dev/null @@ -1,68 +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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.serialization.json.jsonArray -import net.taler.wallet.TAG -import net.taler.wallet.backend.ApiResponse -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, - private val scope: CoroutineScope, -) { - - val pendingOperations = MutableLiveData<List<PendingOperationInfo>>() - - internal fun getPending() { - scope.launch { - val response = walletBackendApi.sendRequest("getPendingOperations") - if (response is ApiResponse.Error) { - Log.i(TAG, "got getPending error result: ${response.error}") - return@launch - } else if (response is ApiResponse.Response) { - Log.i(TAG, "got getPending result") - val pendingList = mutableListOf<PendingOperationInfo>() - val pendingJson = response.result["pendingOperations"]?.jsonArray ?: return@launch - for (i in 0 until pendingJson.size) { - val p = JSONObject(pendingJson[i].toString()) - val type = p.getString("type") - pendingList.add(PendingOperationInfo(type, p)) - } - Log.i(TAG, "Got ${pendingList.size} pending operations") - pendingOperations.postValue((pendingList)) - } - } - } - - fun retryPendingNow() { - scope.launch { - 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/balances/BalanceResponse.kt b/wallet/src/main/java/net/taler/wallet/refund/RefundPaymentInfo.kt index d1a111f..d5f59be 100644 --- a/wallet/src/main/java/net/taler/wallet/balances/BalanceResponse.kt +++ b/wallet/src/main/java/net/taler/wallet/refund/RefundPaymentInfo.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,26 @@ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -package net.taler.wallet.balances +package net.taler.wallet.refund +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class BalanceResponse( - val balances: List<BalanceItem> +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 index bb077f1..b17658a 100644 --- a/wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt @@ -31,28 +31,32 @@ 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.payment.PurchaseDetails import net.taler.wallet.transactions.AmountType -import net.taler.wallet.transactions.DeleteTransactionComposable import net.taler.wallet.transactions.ErrorTransactionButton -import net.taler.wallet.transactions.ExtendedStatus +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.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, - onFulfill: (url: String) -> Unit, - onDelete: () -> Unit, + spec: CurrencySpecification?, + onTransition: (t: TransactionAction) -> Unit, ) { val scrollState = rememberScrollState() Column( @@ -69,23 +73,27 @@ fun TransactionRefundComposable( ) TransactionAmountComposable( label = stringResource(id = R.string.transaction_refund), - amount = t.amountEffective, + amount = t.amountEffective.withSpec(spec), amountType = AmountType.Positive, ) TransactionAmountComposable( label = stringResource(id = R.string.transaction_order_total), - amount = t.amountRaw, + amount = t.amountRaw.withSpec(spec), amountType = AmountType.Neutral, ) - TransactionAmountComposable( - label = stringResource(id = R.string.withdraw_fees), - amount = t.amountRaw - t.amountEffective, - amountType = AmountType.Negative, - ) - PurchaseDetails(info = t.info) { - onFulfill(t.info.fulfillmentUrl ?: "") + 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, + ) } - DeleteTransactionComposable(onDelete) + 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) } @@ -98,21 +106,18 @@ fun TransactionRefundComposablePreview() { val t = TransactionRefund( transactionId = "transactionId", timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - extendedStatus = ExtendedStatus.Pending, - info = TransactionInfo( - orderId = "123", - merchant = ContractMerchant(name = "Taler"), + 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", - fulfillmentMessage = "This is some fulfillment message", - fulfillmentUrl = "https://bank.demo.taler.net/", - products = listOf(), ), refundedTransactionId = "transactionId", - amountRaw = Amount.fromDouble("TESTKUDOS", 42.23), - amountEffective = Amount.fromDouble("TESTKUDOS", 42.1337), + 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, onFulfill = {}) {} + 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 927d4a9..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,6 +18,7 @@ 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.fragment.app.activityViewModels import androidx.preference.Preference @@ -27,12 +28,12 @@ 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.qtart.BuildConfig.WALLET_CORE_VERSION import net.taler.wallet.BuildConfig.FLAVOR import net.taler.wallet.BuildConfig.VERSION_CODE import net.taler.wallet.BuildConfig.VERSION_NAME 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 @@ -47,6 +48,7 @@ class SettingsFragment : PreferenceFragmentCompat() { 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 @@ -55,11 +57,11 @@ class SettingsFragment : PreferenceFragmentCompat() { private lateinit var prefReset: Preference private val devPrefs by lazy { listOf( + prefVersionCore, prefWithdrawTest, prefLogcat, prefExportDb, - prefVersionApp, - prefVersionCore, + prefImportDb, prefVersionExchange, prefVersionMerchant, prefTest, @@ -74,6 +76,10 @@ class SettingsFragment : PreferenceFragmentCompat() { 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) @@ -81,6 +87,7 @@ class SettingsFragment : PreferenceFragmentCompat() { 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")!! @@ -92,18 +99,19 @@ class SettingsFragment : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + 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 } @@ -130,7 +138,10 @@ class SettingsFragment : PreferenceFragmentCompat() { dbExportLauncher.launch("taler-wallet-db-${currentTimeMillis()}.json") true } - + prefImportDb.setOnPreferenceClickListener { + showImportDialog() + true + } prefTest.setOnPreferenceClickListener { model.runIntegrationTest() true @@ -141,15 +152,29 @@ class SettingsFragment : PreferenceFragmentCompat() { } } + 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() { MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) - .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() + .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 index d4c12aa..4e4bbe0 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/ActionButtonComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/ActionButtonComposable.kt @@ -28,9 +28,10 @@ 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.backend.TalerErrorCode -import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer -import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi +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 { @@ -48,25 +49,13 @@ fun ActionButton( tx: TransactionWithdrawal, listener: ActionListener, ) { - if (tx.error != null) { - // There is an error! - when (tx.error.code) { - TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED -> { - KycButton(modifier, tx, listener) - } + 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 -> {} } - } else if (!tx.confirmed) { - // There is a transaction! - if (tx.withdrawalDetails is TalerBankIntegrationApi && - tx.withdrawalDetails.bankConfirmationUrl != null - ) { - // The transaction can be completed with a link! - ConfirmBankButton(modifier, tx, listener) - } else if (tx.withdrawalDetails is ManualTransfer) { - // The transaction must be completed manually! - ConfirmManualButton(modifier, tx, listener) - } } } 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 75ec599..0000000 --- a/wallet/src/main/java/net/taler/wallet/transactions/DeleteTransactionComposable.kt +++ /dev/null @@ -1,54 +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.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 - -@Composable -fun DeleteTransactionComposable(onDelete: () -> Unit) { - Button( - modifier = Modifier.padding(16.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - onClick = onDelete, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.ic_delete), - contentDescription = null, - tint = MaterialTheme.colorScheme.onError, - ) - Text( - modifier = Modifier.padding(start = 8.dp), - text = stringResource(R.string.transactions_delete), - color = MaterialTheme.colorScheme.onError, - ) - } - } -} 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 69c1a8a..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,17 +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 net.taler.common.CurrencySpecification import net.taler.common.exhaustive import net.taler.common.toRelativeTime import net.taler.wallet.R -import net.taler.wallet.transactions.ExtendedStatus.Pending +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() @@ -63,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() @@ -84,7 +97,8 @@ internal class TransactionAdapter( 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,43 +112,98 @@ internal class TransactionAdapter( bindExtraInfo(transaction) time.text = transaction.timestamp.ms.toRelativeTime(context) bindAmount(transaction) - pendingView.visibility = if (transaction.extendedStatus == 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 + } + + 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.extendedStatus == 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.extendedStatus == Pending) amountColor else red) + amount.setTextColor(if (transaction.txState.major == Pending) amountColor else red) } + AmountType.Neutral -> { amount.text = amountStr amount.setTextColor(amountColor) @@ -154,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 3fd37ce..d2be3cf 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt @@ -35,8 +35,12 @@ class TransactionDepositFragment : TransactionDetailFragment() { setContent { TalerSurface { val t = transactionManager.selectedTransaction.observeAsState().value - if (t is TransactionDeposit) TransactionDepositComposable(t, devMode.value) { - onDeleteButtonClicked(t) + 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 678bed2..09ca05b 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt @@ -17,31 +17,33 @@ package net.taler.wallet.transactions import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.annotation.StringRes +import android.util.Log +import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController 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() - val transactionManager by lazy { model.transactionManager } - val devMode by lazy { model.devMode } + 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) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) transactionManager.selectedTransaction.observe(viewLifecycleOwner) { requireActivity().apply { it?.generalTitleRes?.let { @@ -51,44 +53,114 @@ abstract class TransactionDetailFragment : Fragment() { } } - @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") } - @StringRes - protected open val deleteDialogTitle = R.string.transactions_delete - - @StringRes - protected open val deleteDialogMessage = R.string.transactions_delete_dialog_message + 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") + } - @StringRes - protected open val deleteDialogButton = R.string.transactions_delete + 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) + } - protected fun onDeleteButtonClicked(t: Transaction) { + private fun showDialog(tt: TransactionAction, onAction: () -> Unit) { MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) - .setTitle(deleteDialogTitle) - .setMessage(deleteDialogMessage) + .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/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 fcc7787..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,13 +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.transactions.ExtendedStatus.Pending +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() } @@ -43,45 +49,40 @@ class TransactionManager( // 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 selectedCurrency: String? = null + var selectedScope: ScopeInfo? = null val searchQuery = MutableLiveData<String>(null) private val mSelectedTransaction = MutableLiveData<Transaction?>(null) val selectedTransaction: LiveData<Transaction?> = mSelectedTransaction - private val allTransactions = HashMap<String, List<Transaction>>() - private val mTransactions = HashMap<String, MutableLiveData<TransactionsResult>>() + 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.extendedStatus == Pending }, - { it.timestamp.ms }, - { it.transactionId } - ) + val comparator = compareBy<Transaction> { it.txState.major == Pending } transactions.sortWith(comparator) transactions.reverse() // show latest first @@ -96,8 +97,8 @@ class TransactionManager( mSelectedTransaction.value = it } - // update all transactions on UiThread if there was a currency - if (searchQuery == null) allTransactions[currency] = transactions + // update all transactions on UiThread if there was a scope info + if (searchQuery == null) allTransactions[scopeInfo] = transactions } } @@ -122,24 +123,98 @@ class TransactionManager( } } + suspend fun getTransactionById(transactionId: String): Transaction? { + 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 transaction + } + fun selectTransaction(transaction: Transaction) { mSelectedTransaction.postValue(transaction) } - fun deleteTransaction(transactionId: String) = scope.launch { - api.request<Unit>("deleteTransaction") { - put("transactionId", transactionId) - }.onError { - Log.e(TAG, "Error deleteTransaction $it") - }.onSuccess { - // re-load transactions as our list is stale otherwise - loadTransactions() + 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 deleteTransactions(transactionIds: List<String>) { - transactionIds.forEach { id -> - deleteTransaction(id) + 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 e9eb5b8..596a4a9 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt @@ -36,13 +36,13 @@ class TransactionPaymentFragment : TransactionDetailFragment() { setContent { TalerSurface { val t = transactionManager.selectedTransaction.observeAsState().value - val devMode = devMode.observeAsState().value ?: false if (t is TransactionPayment) TransactionPaymentComposable(t, devMode, + balanceManager.getSpecForCurrency(t.amountRaw.currency), onFulfill = { url -> launchInAppBrowser(requireContext(), url) }, - onDelete = { - onDeleteButtonClicked(t) + 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 297c937..27809a7 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt @@ -38,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 @@ -56,8 +57,10 @@ class TransactionPeerFragment : TransactionDetailFragment() { setContent { TalerSurface { val t = transactionManager.selectedTransaction.observeAsState(null).value - if (t != null) TransactionPeerComposable(t, devMode.value) { - onDeleteButtonClicked(t) + if (t != null) TransactionPeerComposable(t, devMode, + balanceManager.getSpecForCurrency(t.amountRaw.currency), + ) { + onTransitionButtonClicked(t, it) } } } @@ -65,7 +68,12 @@ class TransactionPeerFragment : TransactionDetailFragment() { } @Composable -fun TransactionPeerComposable(t: Transaction, devMode: Boolean?, onDelete: () -> Unit) { +fun TransactionPeerComposable( + t: Transaction, + devMode: Boolean, + spec: CurrencySpecification?, + onTransition: (t: TransactionAction) -> Unit, +) { val scrollState = rememberScrollState() Column( modifier = Modifier @@ -80,14 +88,14 @@ fun TransactionPeerComposable(t: Transaction, devMode: Boolean?, onDelete: () -> 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) - if (devMode == true && t.error != null) { + TransitionsComposable(t, devMode, onTransition) + if (devMode && t.error != null) { ErrorTransactionButton(error = t.error!!) } } @@ -102,7 +110,7 @@ fun TransactionAmountComposable(label: String, amount: Amount, amountType: Amoun ) 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) 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 391eefa..e55d887 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt @@ -38,12 +38,17 @@ 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.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() { @@ -55,9 +60,10 @@ class TransactionRefreshFragment : TransactionDetailFragment() { setContent { TalerSurface { val t = transactionManager.selectedTransaction.observeAsState().value - val devMode = devMode.observeAsState().value ?: false - if (t is TransactionRefresh) TransactionRefreshComposable(t, devMode) { - onDeleteButtonClicked(t) + if (t is TransactionRefresh) TransactionRefreshComposable(t, devMode, + balanceManager.getSpecForCurrency(t.amountRaw.currency), + ) { + onTransitionButtonClicked(t, it) } } } @@ -68,7 +74,8 @@ class TransactionRefreshFragment : TransactionDetailFragment() { private fun TransactionRefreshComposable( t: TransactionRefresh, devMode: Boolean, - onDelete: () -> Unit, + spec: CurrencySpecification?, + onTransition: (t: TransactionAction) -> Unit, ) { val scrollState = rememberScrollState() Column( @@ -84,11 +91,11 @@ private fun TransactionRefreshComposable( style = MaterialTheme.typography.bodyLarge, ) TransactionAmountComposable( - label = stringResource(id = R.string.withdraw_fees), - amount = t.amountEffective, + label = stringResource(id = R.string.amount_fee), + amount = t.amountEffective.withSpec(spec), amountType = AmountType.Negative, ) - DeleteTransactionComposable(onDelete) + TransitionsComposable(t, devMode, onTransition) if (devMode && t.error != null) { ErrorTransactionButton(error = t.error) } @@ -101,12 +108,13 @@ private fun TransactionRefreshComposablePreview() { val t = TransactionRefresh( transactionId = "transactionId", timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - extendedStatus = ExtendedStatus.Pending, - amountRaw = Amount.fromDouble("TESTKUDOS", 42.23), - amountEffective = Amount.fromDouble("TESTKUDOS", 42.1337), + 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) {} + 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 61c0364..7992565 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt @@ -23,7 +23,6 @@ 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.launchInAppBrowser import net.taler.wallet.refund.TransactionRefundComposable class TransactionRefundFragment : TransactionDetailFragment() { @@ -36,15 +35,11 @@ class TransactionRefundFragment : TransactionDetailFragment() { setContent { TalerSurface { val t = transactionManager.selectedTransaction.observeAsState().value - val devMode = devMode.observeAsState().value ?: false if (t is TransactionRefund) TransactionRefundComposable(t, devMode, - onFulfill = { url -> - launchInAppBrowser(requireContext(), url) - }, - onDelete = { - onDeleteButtonClicked(t) - } - ) + 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 eb148b8..0000000 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionTipFragment.kt +++ /dev/null @@ -1,127 +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.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.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.compose.TalerSurface -import net.taler.wallet.transactions.ExtendedStatus.Pending - -class TransactionTipFragment : 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 TransactionTip) TransactionTipComposable(t, devMode.value) { - onDeleteButtonClicked(t) - } - } - } - } -} - -@Composable -fun TransactionTipComposable(t: TransactionTip, devMode: Boolean?, 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.bodyLarge, - ) - - 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) - if (devMode == true && t.error != null) { - ErrorTransactionButton(error = t.error) - } - } -} - -@Preview -@Composable -fun TransactionTipPreview() { - val t = TransactionTip( - transactionId = "transactionId", - timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - extendedStatus = Pending, - merchantBaseUrl = "https://merchant.example.org/", - amountRaw = Amount.fromDouble("TESTKUDOS", 42.23), - amountEffective = Amount.fromDouble("TESTKUDOS", 42.1337), - error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED), - ) - Surface { - TransactionTipComposable(t, true) {} - } -} 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 7a85522..27e59bb 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt @@ -38,16 +38,6 @@ class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListene private val model: MainViewModel by activityViewModels() private val withdrawManager by lazy { model.withdrawManager } - private val isPending get() = transactionManager.selectedTransaction.value?.extendedStatus == ExtendedStatus.Pending - - override val deleteDialogTitle: Int - get() = if (isPending) R.string.cancel else super.deleteDialogTitle - override val deleteDialogMessage: Int - get() = if (isPending) R.string.transactions_cancel_dialog_message - else super.deleteDialogMessage - override val deleteDialogButton: Int - get() = if (isPending) R.string.ok else super.deleteDialogButton - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -56,13 +46,13 @@ class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListene setContent { TalerSurface { val t = transactionManager.selectedTransaction.observeAsState().value - val devMode = devMode.observeAsState().value ?: false if (t is TransactionWithdrawal) TransactionWithdrawalComposable( t = t, devMode = devMode, + spec = balanceManager.getSpecForCurrency(t.amountRaw.currency), actionListener = this@TransactionWithdrawalFragment, ) { - onDeleteButtonClicked(t) + onTransitionButtonClicked(t, it) } } } @@ -71,10 +61,12 @@ class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListene override fun onActionButtonClicked(tx: Transaction, type: ActionListener.Type) { when (type) { ActionListener.Type.COMPLETE_KYC -> { - tx.error?.getStringExtra("kycUrl")?.let { kycUrl -> - launchInAppBrowser(requireContext(), kycUrl) + if (tx !is TransactionWithdrawal) return + tx.kycUrl?.let { + launchInAppBrowser(requireContext(), it) } } + ActionListener.Type.CONFIRM_WITH_BANK -> { if (tx !is TransactionWithdrawal) return if (tx.withdrawalDetails !is TalerBankIntegrationApi) return @@ -82,16 +74,17 @@ class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListene launchInAppBrowser(requireContext(), url) } } + ActionListener.Type.CONFIRM_MANUAL -> { if (tx !is TransactionWithdrawal) return if (tx.withdrawalDetails !is ManualTransfer) return - // TODO what if there's more than one or no URI? - if (tx.withdrawalDetails.exchangePaytoUris.isEmpty()) return + if (tx.withdrawalDetails.exchangeCreditAccountDetails.isNullOrEmpty()) return val status = createManualTransferRequired( - amount = tx.amountRaw, - exchangeBaseUrl = tx.exchangeBaseUrl, - uriStr = tx.withdrawalDetails.exchangePaytoUris[0], transactionId = tx.transactionId, + exchangeBaseUrl = tx.exchangeBaseUrl, + amountRaw = tx.amountRaw, + amountEffective = tx.amountEffective, + withdrawalAccountList = tx.withdrawalDetails.exchangeCreditAccountDetails, ) withdrawManager.viewManualWithdrawal(status) findNavController().navigate( 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 6d753ba..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,6 +17,7 @@ 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 @@ -41,7 +42,11 @@ 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 @@ -97,7 +102,8 @@ class TransactionSerializer : KSerializer<Transaction> { sealed class Transaction { abstract val transactionId: String abstract val timestamp: Timestamp - abstract val extendedStatus: ExtendedStatus + abstract val txState: TransactionState + abstract val txActions: List<TransactionAction> abstract val error: TalerErrorInfo? abstract val amountRaw: Amount abstract val amountEffective: Amount @@ -117,33 +123,25 @@ sealed class Transaction { } @Serializable -enum class ExtendedStatus { - @SerialName("pending") - Pending, +enum class TransactionAction { + // Common States + @SerialName("delete") + Delete, - @SerialName("done") - Done, + @SerialName("suspend") + Suspend, - @SerialName("aborting") - Aborting, + @SerialName("resume") + Resume, - @SerialName("aborted") - Aborted, + @SerialName("abort") + Abort, - @SerialName("suspended") - Suspended, + @SerialName("fail") + Fail, - @SerialName("failed") - Failed, - - @SerialName("kyc-required") - KycRequired, - - @SerialName("aml-required") - AmlRequired, - - @SerialName("deleted") - Deleted; + @SerialName("retry") + Retry, } sealed class AmountType { @@ -157,7 +155,9 @@ sealed class AmountType { class TransactionWithdrawal( override val transactionId: String, override val timestamp: Timestamp, - override val extendedStatus: ExtendedStatus, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, + val kycUrl: String? = null, val exchangeBaseUrl: String, val withdrawalDetails: WithdrawalDetails, override val error: TalerErrorInfo? = null, @@ -173,7 +173,7 @@ class TransactionWithdrawal( override fun getTitle(context: Context) = cleanExchange(exchangeBaseUrl) override val generalTitleRes = R.string.withdraw_title val confirmed: Boolean - get() = extendedStatus != ExtendedStatus.Pending && ( + get() = txState.major != Pending && ( (withdrawalDetails is TalerBankIntegrationApi && withdrawalDetails.confirmed) || withdrawalDetails is ManualTransfer ) @@ -184,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 @@ -211,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 extendedStatus: ExtendedStatus, + 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 @@ -238,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. @@ -251,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 extendedStatus: ExtendedStatus, + 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, @@ -286,42 +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 extendedStatus: ExtendedStatus, - 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 extendedStatus: ExtendedStatus, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, override val amountEffective: Amount, @@ -343,7 +388,8 @@ class TransactionRefresh( class TransactionDeposit( override val transactionId: String, override val timestamp: Timestamp, - override val extendedStatus: ExtendedStatus, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, override val amountEffective: Amount, @@ -356,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 @@ -376,7 +425,8 @@ data class PeerInfoShort( class TransactionPeerPullDebit( override val transactionId: String, override val timestamp: Timestamp, - override val extendedStatus: ExtendedStatus, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, val exchangeBaseUrl: String, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, @@ -403,7 +453,8 @@ class TransactionPeerPullDebit( class TransactionPeerPullCredit( override val transactionId: String, override val timestamp: Timestamp, - override val extendedStatus: ExtendedStatus, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, val exchangeBaseUrl: String, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, @@ -431,13 +482,14 @@ class TransactionPeerPullCredit( class TransactionPeerPushDebit( override val transactionId: String, override val timestamp: Timestamp, - override val extendedStatus: ExtendedStatus, + 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 @@ -460,7 +512,8 @@ class TransactionPeerPushDebit( class TransactionPeerPushCredit( override val transactionId: String, override val timestamp: Timestamp, - override val extendedStatus: ExtendedStatus, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, val exchangeBaseUrl: String, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, @@ -480,6 +533,47 @@ class TransactionPeerPushCredit( } /** + * 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( @@ -487,7 +581,8 @@ class DummyTransaction( override val timestamp: Timestamp, override val error: TalerErrorInfo, ) : Transaction() { - override val extendedStatus: ExtendedStatus = ExtendedStatus.Failed + 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 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 5dff704..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,6 +26,7 @@ import android.view.MenuItem import android.view.View import android.view.View.INVISIBLE import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView.OnQueryTextListener import androidx.fragment.app.Fragment @@ -36,28 +38,31 @@ import androidx.recyclerview.selection.StorageStrategy import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import com.google.android.material.dialog.MaterialAlertDialogBuilder -import net.taler.common.Amount 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,10 +128,10 @@ 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 { + ui.actionsBar.receiveButton.setOnClickListener { findNavController().navigate(R.id.action_global_receiveFunds) } ui.mainFab.setOnClickListener { @@ -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) { @@ -180,27 +191,13 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. } } - override fun onActionButtonClicked(transaction: Transaction) { - if (transaction.error != null) { - transaction.handleKyc({ error("Unhandled Action Button Event") }) { error -> - error.getStringExtra("kycUrl")?.let { - launchInAppBrowser(requireContext(), it) - } - } - } else if (transaction is TransactionWithdrawal && !transaction.confirmed) { - if (transaction.withdrawalDetails is WithdrawalDetails.TalerBankIntegrationApi && - transaction.withdrawalDetails.bankConfirmationUrl != null) { - launchInAppBrowser(requireContext(), transaction.withdrawalDetails.bankConfirmationUrl) - } - } - } - 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 @@ -239,25 +236,41 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. when (item.itemId) { R.id.transaction_delete -> { tracker?.selection?.toList()?.let { transactionIds -> - MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) + 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..424cc2a --- /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)) { + 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 fd67e71..9983409 100644 --- a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -36,10 +36,13 @@ 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() { @@ -47,6 +50,8 @@ class PromptWithdrawFragment : Fragment() { private val withdrawManager by lazy { model.withdrawManager } private val transactionManager by lazy { model.transactionManager } + private val selectExchangeDialog = SelectExchangeDialogFragment() + private lateinit var ui: FragmentPromptWithdrawBinding override fun onCreateView( @@ -64,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) @@ -112,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 { @@ -130,6 +139,7 @@ class PromptWithdrawFragment : Fragment() { exchange = s.exchangeBaseUrl, uri = s.talerWithdrawUri, ageRestrictionOptions = s.ageRestrictionOptions, + exchanges = s.possibleExchanges, ) ui.confirmWithdrawButton.apply { text = getString(R.string.withdraw_button_confirm) @@ -151,6 +161,7 @@ class PromptWithdrawFragment : Fragment() { amountEffective: Amount, exchange: String, uri: String?, + exchanges: List<ExchangeItem> = emptyList(), ageRestrictionOptions: List<Int>? = null, ) { model.showProgressBar.value = false @@ -164,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() } } @@ -193,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 index f1a22d3..9bfeda6 100644 --- a/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt @@ -17,23 +17,17 @@ package net.taler.wallet.withdraw 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.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -44,24 +38,32 @@ 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.DeleteTransactionComposable import net.taler.wallet.transactions.ErrorTransactionButton -import net.taler.wallet.transactions.ExtendedStatus 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, - onDelete: () -> Unit, + onTransition: (t: TransactionAction) -> Unit, ) { val scrollState = rememberScrollState() Column( @@ -76,48 +78,39 @@ fun TransactionWithdrawalComposable( text = t.timestamp.ms.toAbsoluteTime(context).toString(), style = MaterialTheme.typography.bodyLarge, ) - TransactionAmountComposable( - label = stringResource(id = R.string.withdraw_total), - amount = t.amountEffective, - amountType = AmountType.Positive, - ) + 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_chosen), - amount = t.amountRaw, - amountType = AmountType.Neutral, - ) - TransactionAmountComposable( - label = stringResource(id = R.string.withdraw_fees), - amount = t.amountRaw - t.amountEffective, - amountType = AmountType.Negative, + 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), ) - if (t.extendedStatus == ExtendedStatus.Pending) { - Button( - modifier = Modifier.padding(16.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - onClick = onDelete, - ) { - Row(verticalAlignment = CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.ic_cancel), - contentDescription = null, - tint = MaterialTheme.colorScheme.onError, - ) - Text( - modifier = Modifier.padding(start = 8.dp), - text = stringResource(R.string.cancel), - color = MaterialTheme.colorScheme.onError, - ) - } - } - } else { - DeleteTransactionComposable(onDelete) - } + + TransitionsComposable(t, devMode, onTransition) + if (devMode && t.error != null) { ErrorTransactionButton(error = t.error) } @@ -130,18 +123,35 @@ fun TransactionWithdrawalComposablePreview() { val t = TransactionWithdrawal( transactionId = "transactionId", timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), - extendedStatus = ExtendedStatus.Pending, + txState = TransactionState(Pending), + txActions = listOf(Retry, Suspend, Abort), exchangeBaseUrl = "https://exchange.demo.taler.net/", - withdrawalDetails = ManualTransfer(exchangePaytoUris = emptyList()), - amountRaw = Amount.fromDouble("TESTKUDOS", 42.23), - amountEffective = Amount.fromDouble("TESTKUDOS", 42.1337), + 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) { - } + override fun onActionButtonClicked(tx: Transaction, type: ActionListener.Type) {} } + Surface { - TransactionWithdrawalComposable(t, true, listener) {} + 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 90b8570..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,36 +64,60 @@ 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 object Withdrawing : WithdrawStatus() + data class Success(val currency: String, val transactionId: String) : WithdrawStatus() - sealed class ManualTransferRequired : WithdrawStatus() { - abstract val uri: Uri - abstract val transactionId: String? - } - data class ManualTransferRequiredIBAN( + class ManualTransferRequired( + val transactionId: String?, + val transactionAmountRaw: Amount, + val transactionAmountEffective: Amount, val exchangeBaseUrl: String, - override val uri: Uri, - val iban: String, - val subject: String, - val amountRaw: Amount, - override val transactionId: String?, - ) : ManualTransferRequired() + val withdrawalTransfers: List<TransferData>, + ) : WithdrawStatus() - data class ManualTransferRequiredBitcoin( - val exchangeBaseUrl: String, - override val uri: Uri, + 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, - val segwitAddrs: List<String>, - val subject: String, - val amountRaw: Amount, - override val transactionId: String?, - ) : ManualTransferRequired() + ): TransferData() - data class Error(val message: String?) : WithdrawStatus() + 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 { @@ -101,11 +134,19 @@ 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 @@ -115,12 +156,9 @@ data class AcceptWithdrawalResponse( @Serializable data class AcceptManualWithdrawalResponse( - val exchangePaytoUris: List<String>, -) - -data class ExchangeSelection( - val amount: Amount, - val talerWithdrawUri: String, + val reservePub: String, + val withdrawalAccountsList: List<WithdrawalExchangeAccountDetails>, + val transactionId: String, ) class WithdrawManager( @@ -131,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 @@ -145,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()) { @@ -158,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, ) } } @@ -174,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 -> @@ -188,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) @@ -210,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, ) } } @@ -234,7 +289,9 @@ class WithdrawManager( exchangeBaseUrl = s.exchangeBaseUrl, amountRaw = s.amountRaw, amountEffective = s.amountEffective, + withdrawalAccountList = s.withdrawalAccountList, ageRestrictionOptions = s.ageRestrictionOptions, + possibleExchanges = s.possibleExchanges, ) } } @@ -275,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)) } /** @@ -301,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..c499c3b 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 @@ -25,6 +25,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import net.taler.common.Amount +import net.taler.common.AmountParserException import net.taler.common.hideKeyboard import net.taler.wallet.MainViewModel import net.taler.wallet.R @@ -73,14 +74,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 fa20072..0000000 --- a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenBitcoin.kt +++ /dev/null @@ -1,169 +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.material3.Button -import androidx.compose.material3.ButtonDefaults -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.Alignment.Companion.End -import androidx.compose.ui.Modifier -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.headlineSmall, - ) - Text( - text = stringResource(R.string.withdraw_manual_bitcoin_intro), - style = MaterialTheme.typography.bodyLarge, - 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(containerColor = MaterialTheme.colorScheme.error), - modifier = Modifier - .padding(vertical = 16.dp) - .align(End), - ) { - Text( - text = stringResource(R.string.withdraw_manual_ready_cancel), - color = MaterialTheme.colorScheme.onError, - ) - } - } - } -} - -@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.bodyLarge, - fontWeight = FontWeight.Normal, - fontSize = 3.em - ) - Text( - text = amount.withCurrency("BTC").toString(), - style = MaterialTheme.typography.bodyLarge, - 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.bodyLarge, - fontWeight = FontWeight.Normal, - fontSize = 3.em, - ) - Text( - text = SEGWIT_MIN.toString(), - style = MaterialTheme.typography.bodyLarge, - 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 537f3ad..0000000 --- a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenIBAN.kt +++ /dev/null @@ -1,160 +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.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.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.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.headlineSmall, - ) - Text( - text = stringResource(R.string.withdraw_manual_ready_intro, - status.amountRaw.toString()), - style = MaterialTheme.typography.bodyLarge, - 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.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) - ) - 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(containerColor = MaterialTheme.colorScheme.error), - modifier = Modifier - .padding(vertical = 16.dp) - .align(Alignment.End), - ) { - Text( - text = stringResource(R.string.withdraw_manual_ready_cancel), - color = MaterialTheme.colorScheme.onError, - ) - } - } - } -} - -@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.bodyLarge, - 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.bodyLarge, - 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 |