commit ad65cd02bfbd7141d38d313f967c43cad3f655f3
parent dadaacaadbf16960170d3f31d4c91c646d687091
Author: Iván Ávalos <avalos@disroot.org>
Date: Wed, 3 Dec 2025 09:53:04 +0100
[wallet] withdrawal improvements, fixes and code cleanup
Diffstat:
4 files changed, 132 insertions(+), 116 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt
@@ -61,7 +61,10 @@ import net.taler.wallet.cleanExchange
data class AmountScope(
val amount: Amount = Amount.zero(scope.currency),
val scope: ScopeInfo,
+ // whether fee calculation should be debounced
val debounce: Boolean = false,
+ // whether it originated from user input
+ val userInput: Boolean = false,
)
@Composable
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt
@@ -53,12 +53,14 @@ import net.taler.wallet.backend.BackendManager
import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.balances.ScopeInfo
import net.taler.wallet.compose.AmountScope
+import net.taler.wallet.compose.EmptyComposable
import net.taler.wallet.compose.LoadingScreen
import net.taler.wallet.compose.TalerSurface
import net.taler.wallet.compose.collectAsStateLifecycleAware
import net.taler.wallet.exchanges.ExchangeItem
import net.taler.wallet.exchanges.SelectExchangeDialogFragment
import net.taler.wallet.withdraw.WithdrawStatus.Status.AlreadyConfirmed
+import net.taler.wallet.withdraw.WithdrawStatus.Status.Confirming
import net.taler.wallet.withdraw.WithdrawStatus.Status.Error
import net.taler.wallet.withdraw.WithdrawStatus.Status.InfoReceived
import net.taler.wallet.withdraw.WithdrawStatus.Status.Loading
@@ -77,7 +79,6 @@ class PromptWithdrawFragment: Fragment() {
private val selectExchangeDialog = SelectExchangeDialogFragment()
- private var startup: Boolean = true
private var editableCurrency: Boolean = true
private var navigating: Boolean = false
@@ -100,6 +101,10 @@ class PromptWithdrawFragment: Fragment() {
val status by withdrawManager.withdrawStatus.collectAsStateLifecycleAware()
val devMode by model.devMode.observeAsState()
+ if (scopes.isEmpty()) EmptyComposable()
+ val initialScope = status.defaultInputScope ?: scope ?: scopes.first()
+ val initialAmount = status.defaultInputAmount ?: Amount.zero(initialScope.currency)
+
LaunchedEffect(status.status) {
if (status.status == None) {
if (withdrawUri != null) {
@@ -108,6 +113,13 @@ class PromptWithdrawFragment: Fragment() {
} else if (withdrawExchangeUri != null) {
// get withdrawal details for taler://withdraw-exchange URI
withdrawManager.prepareManualWithdrawal(withdrawExchangeUri)
+ } else if (exchangeBaseUrl != null) {
+ withdrawManager.getWithdrawalDetailsForExchange(exchangeBaseUrl, loading = true)
+ } else {
+ withdrawManager.getWithdrawalDetailsForAmount(
+ amount = initialAmount,
+ scopeInfo = initialScope,
+ )
}
}
}
@@ -124,18 +136,8 @@ class PromptWithdrawFragment: Fragment() {
TalerSurface {
status.let { s ->
- if (scopes.isEmpty()) {
- LoadingScreen()
- return@let
- }
-
- if (withdrawUri != null && status.uriInfo == null) {
- LoadingScreen()
- return@let
- }
-
when (s.status) {
- AlreadyConfirmed -> LoadingScreen()
+ Confirming, AlreadyConfirmed -> LoadingScreen()
None, Loading, Error, InfoReceived, TosReviewRequired, Updating -> {
val initialScope = s.defaultInputScope ?: scope ?: scopes.first()
@@ -151,19 +153,19 @@ class PromptWithdrawFragment: Fragment() {
scopes = scopes,
onSelectExchange = { selectExchange() },
onSelectAmount = { amount, scope ->
- withdrawManager.getWithdrawalDetails(
+ withdrawManager.getWithdrawalDetailsForAmount(
amount = amount,
scopeInfo = scope,
- exchangeBaseUrl = exchangeBaseUrl,
// only show loading screen when switching currencies
- loading = startup || (exchangeBaseUrl == null && scope != status.selectedScope),
+ loading = scope != status.selectedScope,
)
- startup = false
},
onTosReview = {
// TODO: rewrite ToS review screen in compose
- val args = bundleOf("exchangeBaseUrl" to s.exchangeBaseUrl)
- findNavController().navigate(R.id.action_global_reviewExchangeTos, args)
+ if (s.exchangeBaseUrl != null) {
+ val args = bundleOf("exchangeBaseUrl" to s.exchangeBaseUrl)
+ findNavController().navigate(R.id.action_global_reviewExchangeTos, args)
+ }
},
onConfirm = { age ->
status.selectedScope?.let { model.selectScope(it) }
@@ -240,7 +242,7 @@ class PromptWithdrawFragment: Fragment() {
}
private fun onExchangeSelected(exchange: ExchangeItem) {
- withdrawManager.getWithdrawalDetails(
+ withdrawManager.getWithdrawalDetailsForExchange(
exchangeBaseUrl = exchange.exchangeBaseUrl,
)
}
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt
@@ -85,6 +85,7 @@ data class WithdrawStatus(
None,
Loading,
Updating,
+ Confirming,
InfoReceived,
AlreadyConfirmed,
TosReviewRequired,
@@ -179,14 +180,6 @@ data class WithdrawalDetailsForUri(
@Serializable
data class WithdrawalDetailsForAmount(
/**
- * Did the user accept the current version of the exchange's
- * terms of service?
- *
- * @deprecated the client should query the exchange entry instead
- */
- val tosAccepted: Boolean,
-
- /**
* Amount that the user will transfer to the exchange.
*/
val amountRaw: Amount,
@@ -265,8 +258,6 @@ class WithdrawManager(
private val _withdrawTestStatus = MutableStateFlow<TestWithdrawStatus>(TestWithdrawStatus.None)
val withdrawTestStatus: StateFlow<TestWithdrawStatus> = _withdrawTestStatus.asStateFlow()
- val qrCodes = MutableLiveData<List<QrCodeSpec>>()
-
var exchangeFees: ExchangeFees? = null
private set
@@ -320,22 +311,25 @@ class WithdrawManager(
?: error("transaction ${details.transactionId} not found")
val status = _withdrawStatus.updateAndGet { value ->
- updateInputDefaults(value.copy(
- status = if (tx.txState.major == TransactionMajorState.Dialog) {
- InfoReceived
- } else {
- AlreadyConfirmed
- },
- uriInfo = details.info,
- exchangeBaseUrl = details.info.defaultExchangeBaseUrl,
- ))
+ updateInputDefaults(
+ value.copy(
+ status = if (tx.txState.major == TransactionMajorState.Dialog) {
+ InfoReceived
+ } else {
+ AlreadyConfirmed
+ },
+ uriInfo = details.info,
+ exchangeBaseUrl = details.info.defaultExchangeBaseUrl,
+ )
+ )
}
// then extend with amount details (not for cash acceptor)
if (!status.isCashAcceptor) {
- getWithdrawalDetails(
- amount = details.info.amount,
- exchangeBaseUrl = details.info.defaultExchangeBaseUrl,
+ getWithdrawalDetailsForAmount(
+ amount = details.info.amount
+ ?: Amount.zero(details.info.currency),
+ defaultExchangeBaseUrl = details.info.defaultExchangeBaseUrl,
loading = loading,
)
}
@@ -343,95 +337,87 @@ class WithdrawManager(
}
}
- fun getWithdrawalDetails(
- amount: Amount? = null,
+ fun getWithdrawalDetailsForAmount(
+ amount: Amount,
scopeInfo: ScopeInfo? = null,
- exchangeBaseUrl: String? = null,
+ defaultExchangeBaseUrl: String? = null,
loading: Boolean = true,
) = scope.launch {
- val status = _withdrawStatus.getAndUpdate { value ->
- value.copy(status = if (loading) Loading else Updating)
+ // complete exchangeBaseUrl if missing
+ val exchange = scopeInfo?.let { exchangeManager.findExchange(it) }
+ ?: defaultExchangeBaseUrl?.let { exchangeManager.findExchange(it) }
+ ?: exchangeManager.findExchange(amount.currency)
+ if (exchange != null) {
+ getWithdrawalDetails(
+ amount = amount,
+ exchange = exchange,
+ loading = loading,
+ )
}
+ }
- val ex: ExchangeItem
- val am: Amount?
-
- if (amount != null && (scopeInfo != null || exchangeBaseUrl != null)) {
- // 1. caller sets both parameters
- ex = exchangeBaseUrl?.let { exchangeManager.findExchangeByUrl(it) }
- ?: scopeInfo?.let { exchangeManager.findExchange(it) }
- ?: error("could not resolve exchange")
- am = ex.currency?.let { amount.copy(currency = it) }
- ?: error("could not resolve currency")
- } else if (amount != null) {
- // 2. caller only provides amount
- // => amount is updated
- // => exchange URL is kept
- ex = status.exchangeBaseUrl?.let { exchangeManager.findExchangeByUrl(it) }
- ?: status.selectedScope?.let { exchangeManager.findExchange(it) }
- ?: exchangeManager.findExchange(amount.currency)
- ?: error("could not resolve exchange")
- am = amount
- } else if (exchangeBaseUrl != null) {
- // 3. caller only provides exchange URL
- ex = exchangeManager.findExchangeByUrl(exchangeBaseUrl)
- ?: error("could not resolve exchange")
- am = status.selectedAmount
- ?: ex.currency?.let { Amount.zero(ex.currency) }
- ?: error("could not resolve currency")
- } else if (scopeInfo != null) {
- // 3. caller only provides scope
- ex = exchangeManager.findExchange(scopeInfo)
- ?: error("could not resolve exchange")
- am = status.selectedAmount
- ?: ex.currency?.let { Amount.zero(ex.currency) }
- ?: error("could not resolve currency")
- } else {
- error("no parameters specified")
+ fun getWithdrawalDetailsForExchange(
+ exchangeBaseUrl: String,
+ amount: Amount? = null,
+ loading: Boolean = true,
+ ) = scope.launch {
+ // complete amount if missing
+ val exchange = exchangeManager
+ .findExchangeByUrl(exchangeBaseUrl)
+ if (exchange != null) {
+ val amount = amount
+ ?: exchange.currency?.let { Amount.zero(it)}
+ if (amount != null) {
+ _withdrawStatus.update { value ->
+ updateInputDefaults(value.copy(
+ exchangeBaseUrl = exchangeBaseUrl))
+ }
+
+ getWithdrawalDetails(
+ amount = amount,
+ exchange = exchange,
+ loading = loading,
+ )
+ }
}
+ }
- if (ex.tosStatus != ExchangeTosStatus.Accepted) {
- _withdrawStatus.update { it.copy(status = TosReviewRequired) }
+ fun getWithdrawalDetails(
+ amount: Amount,
+ exchange: ExchangeItem,
+ loading: Boolean = true,
+ ) = scope.launch {
+ // do not interrupt confirmation
+ if (_withdrawStatus.value.status == Confirming) {
return@launch
}
+ _withdrawStatus.update { status ->
+ status.copy(status = if (loading) Loading else Updating)
+ }
+
api.request("getWithdrawalDetailsForAmount", WithdrawalDetailsForAmount.serializer()) {
- put("exchangeBaseUrl", ex.exchangeBaseUrl)
- put("amount", am.toJSONString())
+ put("exchangeBaseUrl", exchange.exchangeBaseUrl)
+ put("amount", amount.toJSONString())
}.onError { error ->
handleError("getWithdrawalDetailsForAmount", error)
}.onSuccess { details ->
scope.launch {
_withdrawStatus.update { value ->
updateSelections(value.copy(
- status = InfoReceived,
+ status = if (exchange.tosStatus != ExchangeTosStatus.Accepted) {
+ TosReviewRequired
+ } else {
+ InfoReceived
+ },
amountInfo = details,
- exchangeBaseUrl = ex.exchangeBaseUrl,
+ exchangeBaseUrl = exchange.exchangeBaseUrl,
))
}
}
}
}
-
- private fun updateSelections(
- status: WithdrawStatus,
- ): WithdrawStatus {
- val selectedAmount = status.amountInfo?.amountRaw
- val selectedScope = status.amountInfo?.scopeInfo
- val selectedSpec = selectedScope?.let { scope ->
- exchangeManager.getSpecForScopeInfo(scope)
- } ?: selectedAmount?.currency?.let { currency ->
- exchangeManager.getSpecForCurrency(currency)
- }
-
- return status.copy(
- selectedAmount = selectedAmount,
- selectedScope = selectedScope,
- selectedSpec = selectedSpec,
- )
- }
-
private suspend fun updateInputDefaults(
status: WithdrawStatus,
): WithdrawStatus {
@@ -456,6 +442,24 @@ class WithdrawManager(
)
}
+ private fun updateSelections(
+ status: WithdrawStatus,
+ ): WithdrawStatus {
+ val selectedAmount = status.amountInfo?.amountRaw
+ val selectedScope = status.amountInfo?.scopeInfo
+ val selectedSpec = selectedScope?.let { scope ->
+ exchangeManager.getSpecForScopeInfo(scope)
+ } ?: selectedAmount?.currency?.let { currency ->
+ exchangeManager.getSpecForCurrency(currency)
+ }
+
+ return status.copy(
+ selectedAmount = selectedAmount,
+ selectedScope = selectedScope,
+ selectedSpec = selectedSpec,
+ )
+ }
+
@UiThread
fun prepareManualWithdrawal(uri: String) = scope.launch {
_withdrawStatus.value = WithdrawStatus(status = Loading)
@@ -464,9 +468,9 @@ class WithdrawManager(
}.onError {
handleError("prepareWithdrawExchange", it)
}.onSuccess {
- getWithdrawalDetails(
- amount = it.amount,
+ getWithdrawalDetailsForExchange(
exchangeBaseUrl = it.exchangeBaseUrl,
+ amount = it.amount,
)
}
}
@@ -491,7 +495,7 @@ class WithdrawManager(
@UiThread
fun acceptWithdrawal(restrictAge: Int? = null) = scope.launch {
val status = _withdrawStatus.updateAndGet { value ->
- value.copy(status = Loading)
+ value.copy(status = Confirming)
}
if (status.talerWithdrawUri == null) {
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt
@@ -90,7 +90,7 @@ fun WithdrawalShowInfo(
val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList()
val keyboardController = LocalSoftwareKeyboardController.current
- var selectedAmount by remember { mutableStateOf(initialAmountScope) }
+ var selectedAmount by remember(initialAmountScope) { mutableStateOf(initialAmountScope) }
var selectedAge by remember { mutableStateOf<Int?>(null) }
val scrollState = rememberScrollState()
val insufficientBalance = remember(selectedAmount, maxAmount) {
@@ -106,7 +106,7 @@ fun WithdrawalShowInfo(
LaunchedEffect(selectedAmount) {
val selected = selectedAmount
- if (!selected.debounce) {
+ if (!selected.debounce && selected.userInput) {
onSelectAmount(
selected.amount,
selected.scope,
@@ -116,7 +116,7 @@ fun WithdrawalShowInfo(
selectedAmount.useDebounce {
val selected = selectedAmount
- if (selected.debounce) {
+ if (selected.debounce && selected.userInput) {
onSelectAmount(
selected.amount,
selected.scope,
@@ -152,11 +152,13 @@ fun WithdrawalShowInfo(
selectedAmount = amount.copy(
amount = Amount.zero(amount.amount.currency),
debounce = false,
+ userInput = true,
)
},
)
- if (status.status == WithdrawStatus.Status.Loading) {
+ if (status.status == WithdrawStatus.Status.Loading
+ || status.status == WithdrawStatus.Status.None) {
LoadingScreen(Modifier.weight(1f))
return
} else if (status.status == Error && status.error != null) {
@@ -183,7 +185,10 @@ fun WithdrawalShowInfo(
showAmount = status.status != TosReviewRequired,
readOnly = status.status == Updating,
onAmountChanged = { amount ->
- selectedAmount = amount.copy(debounce = true)
+ selectedAmount = amount.copy(
+ debounce = true,
+ userInput = true,
+ )
},
label = { Text(stringResource(R.string.amount_withdraw)) },
isError = selectedAmount.amount.isZero() || insufficientBalance,
@@ -194,7 +199,10 @@ fun WithdrawalShowInfo(
},
showShortcuts = true,
onShortcutSelected = { amount ->
- selectedAmount = amount.copy(debounce = false)
+ selectedAmount = amount.copy(
+ debounce = false,
+ userInput = true,
+ )
}
)
@@ -361,7 +369,6 @@ private fun buildPreviewWithdrawStatus(
),
),
amountInfo = WithdrawalDetailsForAmount(
- tosAccepted = true,
amountRaw = Amount.fromJSONString("KUDOS:10.1"),
amountEffective = Amount.fromJSONString("KUDOS:10.2"),
withdrawalAccountsList = emptyList(),