taler-android

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

commit 92bfc9302493eaa91cbc78ca9f3fb105f9632da2
parent 9077c822a1c6b934e91734427dc09209a0c21a90
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 11 Jul 2024 13:39:58 -0600

[wallet] WIP: advanced withdrawal flow

bug 0009011

Diffstat:
Mwallet/src/main/java/net/taler/wallet/HandleUriFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/MainViewModel.kt | 4++--
Mwallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt | 1+
Awallet/src/main/java/net/taler/wallet/withdraw/WithdrawAmountFragment.kt | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt | 47+++++++++++++++++++++++++++++++++++++++++++++--
Mwallet/src/main/res/navigation/nav_graph.xml | 14++++++++++++--
Mwallet/src/main/res/values/strings.xml | 5+++++
7 files changed, 308 insertions(+), 7 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt @@ -119,7 +119,7 @@ class HandleUriFragment: Fragment() { 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) + findNavController().navigate(R.id.action_handleUri_to_withdrawAmount) model.withdrawManager.getWithdrawalDetails(u2) } diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -181,13 +181,13 @@ class MainViewModel( } ?: emptyList() @UiThread - fun createAmount(amountText: String, currency: String): AmountResult { + fun createAmount(amountText: String, currency: String, incoming: Boolean = false): AmountResult { val amount = try { Amount.fromString(currency, amountText) } catch (e: AmountParserException) { return AmountResult.InvalidAmount } - if (hasSufficientBalance(amount)) return AmountResult.Success(amount) + if (incoming || hasSufficientBalance(amount)) return AmountResult.Success(amount) return AmountResult.InsufficientBalance } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -78,6 +78,7 @@ class PromptWithdrawFragment : Fragment() { private fun showWithdrawStatus(status: WithdrawStatus?): Any = when (status) { null -> model.showProgressBar.value = false is Loading -> model.showProgressBar.value = true + is WithdrawStatus.NeedsAmount -> {} // handled in WithdrawAmountFragment is NeedsExchange -> { model.showProgressBar.value = false if (selectExchangeDialog.dialog?.isShowing != true) { diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawAmountFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawAmountFragment.kt @@ -0,0 +1,241 @@ +/* + * 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 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.wallet.AmountResult +import net.taler.wallet.AmountResult.InsufficientBalance +import net.taler.wallet.AmountResult.InvalidAmount +import net.taler.wallet.AmountResult.Success +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.AmountInputField +import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS +import net.taler.wallet.compose.LoadingScreen +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.withdraw.WithdrawStatus.Loading +import net.taler.wallet.withdraw.WithdrawStatus.NeedsAmount + +class WithdrawAmountFragment: Fragment() { + private val model: MainViewModel by activityViewModels() + private val withdrawManager by lazy { model.withdrawManager } + private val balanceManager by lazy { model.balanceManager } + private val exchangeManager by lazy { model.exchangeManager } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setContent { + val status by withdrawManager.withdrawStatus.observeAsState() + val coroutineScope = rememberCoroutineScope() + var defaultExchange by remember { mutableStateOf<ExchangeItem?>(null) } + + TalerSurface { + when (val s = status) { + is Loading -> { + LoadingScreen() + } + + is NeedsAmount -> { + // Find currencySpec for currency or exchange + val exchange = defaultExchange + val spec = if (exchange?.scopeInfo != null) { + balanceManager.getSpecForScopeInfo(exchange.scopeInfo) + } else { + balanceManager.getSpecForCurrency(s.currency) + } + + WithdrawAmountComposable( + status = s, + spec = spec, + onCreateAmount = model::createAmount, + onSubmit = { amount -> + withdrawManager.selectWithdrawalAmount(amount) + } + ) + } + + else -> {} + } + } + + LaunchedEffect(Unit) { + coroutineScope.launch { + val s = status + if (s is NeedsAmount && s.defaultExchangeBaseUrl != null) { + defaultExchange = exchangeManager.findExchangeByUrl(s.defaultExchangeBaseUrl) + } + } + } + } + } + + override fun onStart() { + super.onStart() + withdrawManager.withdrawStatus.observe(viewLifecycleOwner) { status -> + when (status) { + is Loading -> {} + is NeedsAmount -> {} + else -> findNavController().navigate(R.id.action_withdrawAmount_to_promptWithdraw) + } + } + } +} + +@Composable +fun WithdrawAmountComposable( + status: NeedsAmount, + spec: CurrencySpecification?, + onCreateAmount: (str: String, currency: String, incoming: Boolean) -> AmountResult, + onSubmit: (amount: Amount) -> Unit, +) { + var error by remember { mutableStateOf<String?>(null) } + var selectedAmount by remember { + mutableStateOf(status.amount?.amountStr ?: "0") + } + + val supportingText = @Composable { + if (error != null) { Text(error!!) } + } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + ) { + AmountInputField( + modifier = Modifier + .weight(1f) + .padding(top = 16.dp, start = 16.dp, end = 16.dp), + value = selectedAmount, + onValueChange = { + selectedAmount = it + }, + label = { Text(stringResource(R.string.amount_withdraw)) }, + supportingText = supportingText, + isError = error != null, + numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, + ) + + Text( + modifier = Modifier, + text = spec?.symbol ?: status.currency, + softWrap = false, + style = MaterialTheme.typography.titleLarge, + ) + } + + if (status.wireFee != null && !status.wireFee.isZero()) { + TransactionAmountComposable( + label = stringResource(R.string.amount_fee), + amount = status.wireFee, + amountType = AmountType.Negative, + ) + } + + if (status.maxAmount != null) { + TransactionAmountComposable( + label = stringResource(R.string.amount_max), + amount = status.maxAmount, + amountType = AmountType.Neutral, + ) + } + + val context = LocalContext.current + + Button( + modifier = Modifier.padding(top = 16.dp), + onClick = { + when (val res = onCreateAmount(selectedAmount, status.currency, true)) { + is InsufficientBalance -> {} // doesn't apply + is InvalidAmount -> { + error = context.getString(R.string.amount_invalid) + } + is Success -> { + // Check that amount doesn't exceed maximum + if (status.maxAmount != null && res.amount > status.maxAmount) { + error = context.getString(R.string.amount_excess) + } else { + onSubmit(res.amount) + } + } + } + }, + ) { + Text(stringResource(R.string.withdraw_select_amount)) + } + } +} + +@Preview +@Composable +fun WithdrawAmountComposablePreview() { + TalerSurface { + WithdrawAmountComposable( + status = NeedsAmount( + talerWithdrawUri = "taler://withdraw/XYZ", + currency = "KUDOS", + maxAmount = Amount.fromJSONString("KUDOS:100"), + wireFee = Amount.fromJSONString("KUDOS:0.2"), + amount = null, + possibleExchanges = listOf(), + defaultExchangeBaseUrl = null, + ), + spec = null, + onCreateAmount = { _, _, _ -> InvalidAmount }, + onSubmit = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -41,6 +41,16 @@ import net.taler.wallet.withdraw.WithdrawStatus.ReceivedDetails sealed class WithdrawStatus { data class Loading(val talerWithdrawUri: String? = null) : WithdrawStatus() + data class NeedsAmount( + val talerWithdrawUri: String, + val currency: String, + val amount: Amount?, + val maxAmount: Amount?, + val wireFee: Amount?, + val possibleExchanges: List<ExchangeItem>, + val defaultExchangeBaseUrl: String?, + ) : WithdrawStatus() + data class NeedsExchange( val talerWithdrawUri: String, val amount: Amount, @@ -129,7 +139,11 @@ sealed class WithdrawTestStatus { @Serializable data class WithdrawalDetailsForUri( - val amount: Amount, + val amount: Amount?, + val currency: String, + val editableAmount: Boolean, + val maxAmount: Amount?, + val wireFee: Amount?, val defaultExchangeBaseUrl: String?, val possibleExchanges: List<ExchangeItem>, ) @@ -213,7 +227,18 @@ class WithdrawManager( }.onError { error -> handleError("getWithdrawalDetailsForUri", error) }.onSuccess { details -> - if (details.defaultExchangeBaseUrl == null) { + Log.d(TAG, "Withdraw details: $details") + if (details.amount == null || details.editableAmount) { + withdrawStatus.value = WithdrawStatus.NeedsAmount( + talerWithdrawUri = uri, + wireFee = details.wireFee, + amount = details.amount, + maxAmount = details.maxAmount, + currency = details.currency, + possibleExchanges = details.possibleExchanges, + defaultExchangeBaseUrl = details.defaultExchangeBaseUrl, + ) + } else if (details.defaultExchangeBaseUrl == null) { withdrawStatus.value = WithdrawStatus.NeedsExchange( talerWithdrawUri = uri, amount = details.amount, @@ -257,6 +282,24 @@ class WithdrawManager( } } + fun selectWithdrawalAmount(amount: Amount) { + val s = withdrawStatus.value as WithdrawStatus.NeedsAmount + + if (s.defaultExchangeBaseUrl == null) { + withdrawStatus.value = WithdrawStatus.NeedsExchange( + talerWithdrawUri = s.talerWithdrawUri, + amount = amount, + possibleExchanges = s.possibleExchanges, + ) + } else getWithdrawalDetails( + exchangeBaseUrl = s.defaultExchangeBaseUrl, + amount = amount, + showTosImmediately = false, + uri = s.talerWithdrawUri, + possibleExchanges = s.possibleExchanges, + ) + } + @WorkerThread suspend fun prepareManualWithdrawal(uri: String): WithdrawExchangeResponse? { withdrawStatus.postValue(WithdrawStatus.Loading(uri)) diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml @@ -57,8 +57,8 @@ app:popUpTo="@id/nav_main" /> <action - android:id="@+id/action_handleUri_to_promptWithdraw" - app:destination="@id/promptWithdraw" + android:id="@+id/action_handleUri_to_withdrawAmount" + app:destination="@id/withdrawAmount" app:popUpTo="@id/nav_main" /> <action @@ -338,6 +338,16 @@ android:label="@string/transactions_detail_title" /> <fragment + android:id="@+id/withdrawAmount" + android:name="net.taler.wallet.withdraw.WithdrawAmountFragment" + android:label="@string/withdraw_title"> + <action + android:id="@+id/action_withdrawAmount_to_promptWithdraw" + app:destination="@id/promptWithdraw" + app:popUpTo="@id/nav_main"/> + </fragment> + + <fragment android:id="@+id/promptWithdraw" android:name="net.taler.wallet.withdraw.PromptWithdrawFragment" android:label="@string/nav_prompt_withdraw" diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -93,6 +93,8 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="amount_invoiced">Amount invoiced</string> <string name="amount_lost">Amount lost</string> <string name="amount_negative">-%s</string> + <string name="amount_max">Maximum amount</string> + <string name="amount_excess">Amount exceeds maximum</string> <string name="amount_positive">+%s</string> <string name="amount_receive">Amount to receive</string> <string name="amount_received">Amount received</string> @@ -101,6 +103,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="amount_total">Total amount</string> <string name="amount_total_label">Total:</string> <string name="amount_transfer">Transfer</string> + <string name="amount_withdraw">Amount to withdraw</string> <!-- Balances --> @@ -234,6 +237,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="withdraw_error_test">Error withdrawing TESTKUDOS</string> <string name="withdraw_error_title">Withdrawal Error</string> <string name="withdraw_exchange">Provider</string> + <string name="withdraw_fee">+%1$s withdrawal fees</string> <string name="withdraw_initiated">Withdrawal initiated</string> <string name="withdraw_manual_bitcoin_intro">Now make a split transaction with the following three outputs.</string> <string name="withdraw_manual_check_fees">Check fees</string> @@ -251,6 +255,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="withdraw_manual_title">Make a manual transfer to the provider</string> <string name="withdraw_restrict_age">Restrict Usage to Age</string> <string name="withdraw_restrict_age_unrestricted">Unrestricted</string> + <string name="withdraw_select_amount">Select amount</string> <string name="withdraw_subtitle">Select target bank account</string> <string name="withdraw_title">Withdrawal</string> <string name="withdraw_waiting_confirm">Waiting for confirmation</string>