taler-android

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

commit dadaacaadbf16960170d3f31d4c91c646d687091
parent d26120f9d8e8b5dd2c2b3e176c373daa01205e99
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed,  3 Dec 2025 00:13:15 +0100

[wallet] speed and efficiency improvements across all send/receive flows

Diffstat:
Mtaler-kotlin-android/src/main/java/net/taler/common/Amount.kt | 4++++
Mwallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt | 162++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mwallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt | 6++++--
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt | 24+++++++++++++++---------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt | 4+---
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt | 4++--
Mwallet/src/main/java/net/taler/wallet/peer/PeerManager.kt | 29+++++++++++++++++++++++++++--
Mwallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt | 87++++++++++++++++++++++++-------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt | 117+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
11 files changed, 310 insertions(+), 217 deletions(-)

diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt @@ -280,6 +280,10 @@ data class Amount( } } + override fun equals(other: Any?): Boolean { + return other is Amount && (value == other.value && fraction == other.fraction) + } + override fun compareTo(other: Amount): Int { check(currency == other.currency) { "Can only compare amounts with the same currency" } when { diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt @@ -311,7 +311,7 @@ internal fun AmountInputFieldBase( interactionSource = interactionSource, enabled = enabled, trailingIcon = { - if (!amount.isZero()) IconButton(onClick = { + if (!readOnly && !amount.isZero()) IconButton(onClick = { onAmountChanged(amount.minus(amount)) }) { Icon( diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt @@ -29,11 +29,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.DropdownMenu +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -54,8 +59,9 @@ import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.cleanExchange data class AmountScope( - val amount: Amount, + val amount: Amount = Amount.zero(scope.currency), val scope: ScopeInfo, + val debounce: Boolean = false, ) @Composable @@ -71,13 +77,13 @@ fun AmountScopeField( readOnly: Boolean = false, keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), - enabledAmount: Boolean = true, - enabledScope: Boolean = true, + showAmount: Boolean = true, + showScope: Boolean = true, showShortcuts: Boolean = false, onShortcutSelected: ((amount: AmountScope) -> Unit)? = null, ) { Column(modifier) { - if (editableScope) { + if (showScope) { ScopeDropdown( modifier = Modifier .fillMaxWidth() @@ -90,11 +96,11 @@ fun AmountScopeField( )) }, initialScope = amount.scope, - readOnly = readOnly || !enabledScope, + readOnly = readOnly || !editableScope, ) } - if (enabledAmount) { + if (showAmount) { AmountInputFieldBase( modifier = Modifier .fillMaxWidth(), @@ -130,6 +136,7 @@ fun AmountScopeField( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ScopeDropdown( scopes: List<ScopeInfo>, @@ -159,70 +166,89 @@ fun ScopeDropdown( val enabled = false val interactionSource = remember { MutableInteractionSource() } - BasicTextField( - value = value, - modifier = Modifier - .height(45.dp) - .clickable { if (!readOnly) expanded = true } - .fillMaxWidth(), - onValueChange = { }, - enabled = enabled, - readOnly = true, - textStyle = TextStyle(color = colors.focusedTextColor), - interactionSource = interactionSource, - singleLine = singleLine, - decorationBox = - @Composable { innerTextField -> - OutlinedTextFieldDefaults.DecorationBox( - value = value, - innerTextField = innerTextField, - singleLine = singleLine, - enabled = enabled, - visualTransformation = VisualTransformation.None, - interactionSource = interactionSource, - prefix = { - Text( - modifier = Modifier.padding(end = 6.dp), - text = stringResource(R.string.currency_via), - ) - }, - contentPadding = OutlinedTextFieldDefaults.contentPadding( - top = 0.dp, - bottom = 0.dp, - ), - colors = TextFieldDefaults.colors(), - ) - }, - ) - - DropdownMenu( + ExposedDropdownMenuBox( expanded = expanded, - onDismissRequest = { expanded = false }, + onExpandedChange = { expanded = it }, modifier = Modifier, ) { - scopes.forEachIndexed { index, s -> - DropdownMenuItem( - text = { - Text(text = when (s) { - is ScopeInfo.Global -> s.currency - is ScopeInfo.Exchange -> stringResource( - R.string.currency_url, - s.currency, - cleanExchange(s.url), - ) - is ScopeInfo.Auditor -> stringResource( - R.string.currency_url, - s.currency, - cleanExchange(s.url), - ) - }) + BasicTextField( + value = value, + modifier = Modifier + .height(45.dp) + .clickable { if (!readOnly) expanded = true } + .fillMaxWidth(), + onValueChange = { }, + enabled = enabled, + readOnly = true, + textStyle = TextStyle(color = colors.focusedTextColor), + interactionSource = interactionSource, + singleLine = singleLine, + decorationBox = + @Composable { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + innerTextField = innerTextField, + singleLine = singleLine, + enabled = enabled, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + prefix = { + Text( + modifier = Modifier.padding(end = 6.dp), + text = stringResource(R.string.currency_via), + ) + }, + contentPadding = OutlinedTextFieldDefaults.contentPadding( + top = 0.dp, + bottom = 0.dp, + ), + colors = ExposedDropdownMenuDefaults.textFieldColors(), + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded) + } + ) }, - onClick = { - selectedIndex = index - onScopeChanged(scopes[index]) - expanded = false - } - ) + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = MenuDefaults.containerColor, + shape = MenuDefaults.shape, + ) { + scopes.forEachIndexed { index, s -> + DropdownMenuItem( + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + leadingIcon = { + if (selectedIndex == index) { + Icon(Icons.Filled.Check, contentDescription = null) + } + }, + text = { + Text( + text = when (s) { + is ScopeInfo.Global -> s.currency + is ScopeInfo.Exchange -> stringResource( + R.string.currency_url, + s.currency, + cleanExchange(s.url), + ) + + is ScopeInfo.Auditor -> stringResource( + R.string.currency_url, + s.currency, + cleanExchange(s.url), + ) + } + ) + }, + onClick = { + selectedIndex = index + onScopeChanged(scopes[index]) + expanded = false + } + ) + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt b/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt @@ -25,9 +25,11 @@ import androidx.compose.ui.Modifier import net.taler.wallet.systemBarsPaddingBottom @Composable -fun LoadingScreen() { +fun LoadingScreen( + modifier: Modifier = Modifier, +) { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .systemBarsPaddingBottom(), contentAlignment = Alignment.Center, diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -95,11 +95,17 @@ fun OutgoingPullComposable( val tosReview = checkResult != null && checkResult?.tosStatus != ExchangeTosStatus.Accepted amount.amount.useDebounce { - if (!amount.amount.isZero()) { + if (amount.debounce) { checkResult = checkPeerPullCredit(amount, false) } } + LaunchedEffect(amount) { + if (!amount.debounce) { + checkResult = checkPeerPullCredit(amount, true) + } + } + if (state is OutgoingChecking || state is OutgoingCreating || state is OutgoingResponse) { @@ -110,10 +116,6 @@ fun OutgoingPullComposable( val amountFocusRequester = remember { FocusRequester() } val subjectFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - amountFocusRequester.requestFocus() - } - Column( Modifier .fillMaxSize() @@ -136,20 +138,24 @@ fun OutgoingPullComposable( scopes = scopes, readOnly = false, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - enabledAmount = !tosReview, + showAmount = !tosReview, showShortcuts = true, onAmountChanged = { - amount = it + amount = it.copy(debounce = amount.scope == it.scope) shortcutSelected = false }, onShortcutSelected = { - amount = it + amount = it.copy(debounce = true) shortcutSelected = true }, isError = amount.amount.isZero(), label = { Text(stringResource(R.string.amount_receive)) }, ) + LaunchedEffect(tosReview) { + if (!tosReview) amountFocusRequester.requestFocus() + } + if (state is OutgoingError) { ErrorComposable(state.info, devMode, onClose) return@Column @@ -193,7 +199,7 @@ fun OutgoingPullComposable( }, ) - if (res != null) { + if (res != null && res.amountRaw != null && res.amountEffective != null) { if (res.amountEffective > res.amountRaw) { val fee = res.amountEffective - res.amountRaw Text( diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -116,9 +116,7 @@ fun OutgoingPushIntroComposable( var hours by rememberSaveable { mutableLongStateOf(DEFAULT_EXPIRY.hours) } amount.useDebounce { - if (!amount.amount.isZero()) { - feeResult = getFees(it) ?: None() - } + feeResult = getFees(it) ?: None() } val amountFocusRequester = remember { FocusRequester() } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt @@ -57,8 +57,8 @@ data class CheckPeerPullCreditResponse( @Serializable data class CheckPeerPullCreditResult( val exchangeBaseUrl: String, - val amountRaw: Amount, - val amountEffective: Amount, + val amountRaw: Amount? = null, + val amountEffective: Amount? = null, val tosStatus: ExchangeTosStatus?, ) diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -22,11 +22,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import net.taler.common.Amount import net.taler.common.Timestamp @@ -110,6 +108,22 @@ class PeerManager( _outgoingPullState.value = OutgoingChecking } + if (exchangeItem.tosStatus != ExchangeTosStatus.Accepted) { + _outgoingPullState.value = OutgoingIntro + return CheckPeerPullCreditResult( + tosStatus = exchangeItem.tosStatus, + exchangeBaseUrl = exchangeItem.exchangeBaseUrl, + ) + } else if (amount.isZero()) { + _outgoingPullState.value = OutgoingIntro + return CheckPeerPullCreditResult( + tosStatus = exchangeItem.tosStatus, + exchangeBaseUrl = exchangeItem.exchangeBaseUrl, + amountRaw = amount, + amountEffective = amount, + ) + } + api.request("checkPeerPullCredit", CheckPeerPullCreditResponse.serializer()) { put("restrictScope", JSONObject(BackendManager.json.encodeToString(scopeInfo))) put("amount", amount.toJSONString()) @@ -165,6 +179,17 @@ class PeerManager( maxDepositAmountEffective = max?.effectiveAmount, maxDepositAmountRaw = max?.rawAmount, ) + + if (amount.isZero() && exchangeBaseUrl != null) { + return CheckFeeResult.Success( + amountRaw = amount, + amountEffective = amount, + maxDepositAmountEffective = max?.effectiveAmount, + maxDepositAmountRaw = max?.rawAmount, + exchangeBaseUrl = exchangeBaseUrl, + ) + } + api.request("checkPeerPushDebitV2", CheckPeerPushDebitResponse.serializer()) { exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } restrictScope?.let { put("restrictScope", JSONObject(BackendManager.json.encodeToString(it))) } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -30,7 +30,6 @@ 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.remember import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -44,7 +43,6 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar.LENGTH_LONG -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import net.taler.common.Amount import net.taler.common.EventObserver @@ -54,6 +52,7 @@ import net.taler.wallet.main.ViewMode 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.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware @@ -78,9 +77,9 @@ class PromptWithdrawFragment: Fragment() { private val selectExchangeDialog = SelectExchangeDialogFragment() + private var startup: Boolean = true private var editableCurrency: Boolean = true private var navigating: Boolean = false - private var acceptingTos: Boolean = false override fun onCreateView( inflater: LayoutInflater, @@ -101,16 +100,6 @@ class PromptWithdrawFragment: Fragment() { val status by withdrawManager.withdrawStatus.collectAsStateLifecycleAware() val devMode by model.devMode.observeAsState() - val exchange by remember(status.exchangeBaseUrl) { - status.exchangeBaseUrl - ?.let { exchangeManager.findExchangeForBaseUrl(it) } - ?: MutableStateFlow(null) - }.collectAsStateLifecycleAware(null) - - val defaultScope = scope - ?: status.scopeInfo - ?: scopes.firstOrNull() - LaunchedEffect(status.status) { if (status.status == None) { if (withdrawUri != null) { @@ -119,29 +108,13 @@ class PromptWithdrawFragment: Fragment() { } else if (withdrawExchangeUri != null) { // get withdrawal details for taler://withdraw-exchange URI withdrawManager.prepareManualWithdrawal(withdrawExchangeUri) - } else if (defaultScope != null && !status.isCashAcceptor) { - // get withdrawal details for available data - withdrawManager.getWithdrawalDetails( - amount = amount ?: Amount.zero(defaultScope.currency), - scopeInfo = scope ?: defaultScope, - exchangeBaseUrl = exchangeBaseUrl, - loading = true, - ) } } } - val currencySpec = remember(exchange?.scopeInfo) { - exchange?.scopeInfo?.let { scopeInfo -> - exchangeManager.getSpecForScopeInfo(scopeInfo) - } ?: status.currency?.let { - exchangeManager.getSpecForCurrency(it) - } - } - - LaunchedEffect(currencySpec, amount) { + LaunchedEffect(status.selectedSpec, amount) { (requireActivity() as AppCompatActivity).apply { - supportActionBar?.title = currencySpec?.symbol?.let { symbol -> + supportActionBar?.title = status.selectedSpec?.symbol?.let { symbol -> getString(R.string.nav_prompt_withdraw_currency, symbol) } ?: amount?.currency?.let { currency -> getString(R.string.nav_prompt_withdraw_currency, currency) @@ -151,33 +124,41 @@ class PromptWithdrawFragment: Fragment() { TalerSurface { status.let { s -> - if (defaultScope == null) { + if (scopes.isEmpty()) { + LoadingScreen() + return@let + } + + if (withdrawUri != null && status.uriInfo == null) { LoadingScreen() return@let } when (s.status) { - Loading, AlreadyConfirmed -> LoadingScreen() + AlreadyConfirmed -> LoadingScreen() - None, Error, InfoReceived, TosReviewRequired, Updating -> { - // TODO: use scopeInfo instead of currency! + None, Loading, Error, InfoReceived, TosReviewRequired, Updating -> { + val initialScope = s.defaultInputScope ?: scope ?: scopes.first() + val initialAmount = s.defaultInputAmount ?: Amount.zero(initialScope.currency) WithdrawalShowInfo( status = s, devMode = devMode ?: false, - defaultScope = defaultScope, + initialAmountScope = AmountScope( + amount = initialAmount, + scope = initialScope, + ), editableScope = editableCurrency, scopes = scopes, - spec = currencySpec, - onSelectExchange = { - selectExchange() - }, + onSelectExchange = { selectExchange() }, onSelectAmount = { amount, scope -> withdrawManager.getWithdrawalDetails( amount = amount, scopeInfo = scope, + exchangeBaseUrl = exchangeBaseUrl, // only show loading screen when switching currencies - loading = scope != status.scopeInfo, + loading = startup || (exchangeBaseUrl == null && scope != status.selectedScope), ) + startup = false }, onTosReview = { // TODO: rewrite ToS review screen in compose @@ -185,7 +166,7 @@ class PromptWithdrawFragment: Fragment() { findNavController().navigate(R.id.action_global_reviewExchangeTos, args) }, onConfirm = { age -> - exchange?.scopeInfo?.let { model.selectScope(it) } + status.selectedScope?.let { model.selectScope(it) } withdrawManager.acceptWithdrawal(age) }, ) @@ -240,24 +221,6 @@ class PromptWithdrawFragment: Fragment() { } } - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - withdrawManager.withdrawStatus.collect { status -> - when (status.status) { - TosReviewRequired -> { - if (!acceptingTos && model.viewMode.value is ViewMode.Transactions) { - acceptingTos = true - val args = bundleOf("exchangeBaseUrl" to status.exchangeBaseUrl) - findNavController().navigate(R.id.action_global_reviewExchangeTos, args) - } else return@collect - } - - else -> {} - } - } - } - } - selectExchangeDialog.exchangeSelection.observe(viewLifecycleOwner, EventObserver { onExchangeSelected(it) }) @@ -271,7 +234,9 @@ class PromptWithdrawFragment: Fragment() { private fun selectExchange() { val exchanges = withdrawManager.withdrawStatus.value.uriInfo?.possibleExchanges ?: return selectExchangeDialog.setExchanges(exchanges) - selectExchangeDialog.show(parentFragmentManager, "SELECT_EXCHANGE") + if (selectExchangeDialog.isAdded) { + selectExchangeDialog.show(parentFragmentManager, "SELECT_EXCHANGE") + } } private fun onExchangeSelected(exchange: ExchangeItem) { diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -43,6 +43,7 @@ import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails import net.taler.wallet.withdraw.WithdrawStatus.Status.* import androidx.core.net.toUri import kotlinx.coroutines.runBlocking +import net.taler.common.CurrencySpecification import net.taler.wallet.transactions.TransactionMajorState import net.taler.wallet.transactions.TransactionManager @@ -63,11 +64,19 @@ data class WithdrawStatus( val error: TalerErrorInfo? = null, // received details - val currency: String? = null, - val scopeInfo: ScopeInfo? = null, val uriInfo: WithdrawalDetailsForUri? = null, val amountInfo: WithdrawalDetailsForAmount? = null, + // calculated input defaults (based on uriInfo or exchangeBaseUrl) + val defaultInputAmount: Amount? = null, + val defaultInputScope: ScopeInfo? = null, + val defaultInputSpec: CurrencySpecification? = null, + + // calculated selections (based on amountInfo) + val selectedAmount: Amount? = null, + val selectedScope: ScopeInfo? = null, + val selectedSpec: CurrencySpecification? = null, + // manual transfer val manualTransferResponse: AcceptManualWithdrawalResponse? = null, val withdrawalTransfers: List<TransferData> = emptyList(), @@ -309,18 +318,17 @@ class WithdrawManager( scope.launch { val tx = transactionManager.getTransactionById(details.transactionId) ?: error("transaction ${details.transactionId} not found") + val status = _withdrawStatus.updateAndGet { value -> - value.copy( + updateInputDefaults(value.copy( status = if (tx.txState.major == TransactionMajorState.Dialog) { InfoReceived } else { AlreadyConfirmed }, uriInfo = details.info, - currency = details.info.currency, exchangeBaseUrl = details.info.defaultExchangeBaseUrl, - transactionId = details.transactionId, - ) + )) } // then extend with amount details (not for cash acceptor) @@ -360,7 +368,7 @@ class WithdrawManager( // => amount is updated // => exchange URL is kept ex = status.exchangeBaseUrl?.let { exchangeManager.findExchangeByUrl(it) } - ?: status.scopeInfo?.let { exchangeManager.findExchange(it) } + ?: status.selectedScope?.let { exchangeManager.findExchange(it) } ?: exchangeManager.findExchange(amount.currency) ?: error("could not resolve exchange") am = amount @@ -368,20 +376,25 @@ class WithdrawManager( // 3. caller only provides exchange URL ex = exchangeManager.findExchangeByUrl(exchangeBaseUrl) ?: error("could not resolve exchange") - am = status.amountInfo?.amountRaw + 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.amountInfo?.amountRaw + am = status.selectedAmount ?: ex.currency?.let { Amount.zero(ex.currency) } ?: error("could not resolve currency") } else { error("no parameters specified") } + if (ex.tosStatus != ExchangeTosStatus.Accepted) { + _withdrawStatus.update { it.copy(status = TosReviewRequired) } + return@launch + } + api.request("getWithdrawalDetailsForAmount", WithdrawalDetailsForAmount.serializer()) { put("exchangeBaseUrl", ex.exchangeBaseUrl) put("amount", am.toJSONString()) @@ -390,22 +403,59 @@ class WithdrawManager( }.onSuccess { details -> scope.launch { _withdrawStatus.update { value -> - value.copy( - status = if (ex.tosStatus != ExchangeTosStatus.Accepted) { - TosReviewRequired - } else { - InfoReceived - }, - exchangeBaseUrl = ex.exchangeBaseUrl, + updateSelections(value.copy( + status = InfoReceived, amountInfo = details, - currency = details.amountRaw.currency, - scopeInfo = details.scopeInfo, - ) + exchangeBaseUrl = ex.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 { + val defaultAmount = status.uriInfo?.amount + + val defaultScope = status.exchangeBaseUrl?.let { url -> + exchangeManager.findExchangeByUrl(url)?.scopeInfo + } ?: status.uriInfo?.defaultExchangeBaseUrl?.let { url -> + exchangeManager.findExchangeByUrl(url)?.scopeInfo + } + + val defaultSpec = defaultScope?.let { scope -> + exchangeManager.getSpecForScopeInfo(scope) + } ?: defaultAmount?.currency?.let { currency -> + exchangeManager.getSpecForCurrency(currency) + } + + return status.copy( + defaultInputAmount = defaultAmount, + defaultInputScope = defaultScope, + defaultInputSpec = defaultSpec, + ) + } + @UiThread fun prepareManualWithdrawal(uri: String) = scope.launch { _withdrawStatus.value = WithdrawStatus(status = Loading) diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt @@ -49,13 +49,13 @@ 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.wallet.R import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.cleanExchange import net.taler.wallet.compose.AmountScope import net.taler.wallet.compose.AmountScopeField import net.taler.wallet.compose.BottomButtonBox +import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.WarningLabel import net.taler.wallet.exchanges.ExchangeItem @@ -74,49 +74,54 @@ import net.taler.wallet.withdraw.WithdrawalOperationStatusFlag.Pending fun WithdrawalShowInfo( status: WithdrawStatus, devMode: Boolean, - defaultScope: ScopeInfo, + initialAmountScope: AmountScope, editableScope: Boolean, scopes: List<ScopeInfo>, - spec: CurrencySpecification?, onSelectAmount: (amount: Amount, scope: ScopeInfo) -> Unit, onSelectExchange: () -> Unit, onTosReview: () -> Unit, onConfirm: (age: Int?) -> Unit, ) { - val defaultAmount = status.amountInfo?.amountRaw - ?: status.uriInfo?.amount - ?: Amount.zero(defaultScope.currency) val maxAmount = status.uriInfo?.maxAmount val editableAmount = status.uriInfo?.editableAmount ?: true - val wireFee = status.uriInfo?.wireFee ?: Amount.zero(defaultScope.currency) + val wireFee = status.uriInfo?.wireFee val exchange = status.exchangeBaseUrl val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList() val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() val keyboardController = LocalSoftwareKeyboardController.current - var selectedAmount by remember { mutableStateOf(AmountScope(defaultAmount, defaultScope)) } + var selectedAmount by remember { mutableStateOf(initialAmountScope) } var selectedAge by remember { mutableStateOf<Int?>(null) } val scrollState = rememberScrollState() val insufficientBalance = remember(selectedAmount, maxAmount) { - maxAmount == null || selectedAmount.amount > maxAmount + val amount = selectedAmount.amount + if (maxAmount != null && amount.currency == maxAmount.currency) { + amount > maxAmount + } else { + false + } } - var startup by remember { mutableStateOf(true) } - selectedAmount.useDebounce { - if (startup) { // do not fire at startup - startup = false - } else { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(selectedAmount) { + val selected = selectedAmount + if (!selected.debounce) { onSelectAmount( - selectedAmount.amount, - selectedAmount.scope, + selected.amount, + selected.scope, ) } } - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() + selectedAmount.useDebounce { + val selected = selectedAmount + if (selected.debounce) { + onSelectAmount( + selected.amount, + selected.scope, + ) + } } Column( @@ -136,18 +141,25 @@ fun WithdrawalShowInfo( .padding(horizontal = 16.dp) .fillMaxWidth(), amount = selectedAmount.copy( - amount = selectedAmount.amount.withSpec(spec) + amount = selectedAmount.amount.withSpec(status.selectedSpec) ), scopes = scopes, - editableScope = true, - enabledAmount = false, + showAmount = false, + showScope = true, showShortcuts = false, + readOnly = status.status == Updating, onAmountChanged = { amount -> - selectedAmount = amount + selectedAmount = amount.copy( + amount = Amount.zero(amount.amount.currency), + debounce = false, + ) }, ) - if (status.status == Error && status.error != null) { + if (status.status == WithdrawStatus.Status.Loading) { + LoadingScreen(Modifier.weight(1f)) + return + } else if (status.status == Error && status.error != null) { WithdrawalError(status.error) return } else if (status.isCashAcceptor) { @@ -165,22 +177,16 @@ fun WithdrawalShowInfo( .fillMaxWidth() .focusRequester(focusRequester), amount = selectedAmount.copy( - amount = selectedAmount.amount.withSpec(spec)), + amount = selectedAmount.amount.withSpec(status.selectedSpec)), scopes = scopes, - editableScope = false, - enabledAmount = status.status != TosReviewRequired, + showScope = false, + showAmount = status.status != TosReviewRequired, + readOnly = status.status == Updating, onAmountChanged = { amount -> - selectedAmount = if (amount.scope != status.scopeInfo) { - // if amount changes, reset to zero! - amount.copy(amount = Amount.zero(amount.scope.currency)) - } else { - amount - } + selectedAmount = amount.copy(debounce = true) }, label = { Text(stringResource(R.string.amount_withdraw)) }, - isError = selectedAmount.amount.isZero() - || maxAmount != null - && selectedAmount.amount > maxAmount, + isError = selectedAmount.amount.isZero() || insufficientBalance, supportingText = { if (insufficientBalance && maxAmount != null) { Text(stringResource(R.string.amount_excess, maxAmount)) @@ -188,23 +194,27 @@ fun WithdrawalShowInfo( }, showShortcuts = true, onShortcutSelected = { amount -> - selectedAmount = amount + selectedAmount = amount.copy(debounce = false) } ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + if (status.status == TosReviewRequired) Text( modifier = Modifier.padding(22.dp), text = stringResource(R.string.withdraw_review_terms), ) - } else { + } else if (status.amountInfo != null) { TransactionAmountComposable( - label = if (wireFee.isZero()) { + label = if (wireFee != null && wireFee.isZero()) { stringResource(R.string.amount_total) } else { stringResource(R.string.amount_chosen) }, - amount = selectedAmount.amount, - amountType = if (wireFee.isZero()) { + amount = status.amountInfo.amountRaw.withSpec(status.selectedSpec), + amountType = if (wireFee != null && wireFee.isZero()) { AmountType.Positive } else { AmountType.Neutral @@ -212,16 +222,19 @@ fun WithdrawalShowInfo( ) } - if (status.status != TosReviewRequired && !wireFee.isZero()) { + if (status.status != TosReviewRequired + && status.amountInfo != null + && wireFee != null + && !wireFee.isZero()) { TransactionAmountComposable( label = stringResource(R.string.amount_fee), - amount = wireFee, + amount = wireFee.withSpec(status.selectedSpec), amountType = AmountType.Negative, ) TransactionAmountComposable( label = stringResource(R.string.amount_total), - amount = selectedAmount.amount + wireFee, + amount = status.amountInfo.amountEffective.withSpec(status.selectedSpec) + wireFee, amountType = AmountType.Positive, ) } @@ -319,7 +332,6 @@ private fun buildPreviewWithdrawStatus( ) = WithdrawStatus( status = status, talerWithdrawUri = "taler://", - currency = "KUDOS", exchangeBaseUrl = "exchange.head.taler.net", transactionId = "tx:343434", error = null, @@ -368,14 +380,16 @@ fun WithdrawalShowInfoUpdatingPreview() { WithdrawalShowInfo( status = buildPreviewWithdrawStatus(Updating), devMode = true, - defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + initialAmountScope = AmountScope( + amount = Amount.fromJSONString("KUDOS:10.10"), + scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ), editableScope = true, scopes = listOf( ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), ScopeInfo.Global("CHF"), ), - spec = null, onSelectExchange = {}, onSelectAmount = { _, _ -> }, onTosReview = {}, @@ -391,14 +405,17 @@ fun WithdrawalShowInfoTosReviewPreview() { WithdrawalShowInfo( status = buildPreviewWithdrawStatus(TosReviewRequired), devMode = true, - defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), +// defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + initialAmountScope = AmountScope( + amount = Amount.fromJSONString("KUDOS:10.10"), + scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ), editableScope = true, scopes = listOf( ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), ScopeInfo.Global("CHF"), ), - spec = null, onSelectExchange = {}, onSelectAmount = { _, _ -> }, onTosReview = {},