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:
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>