taler-android

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

commit 5ca0e7594cde59f6bb9e65abb27d9195d8685af6
parent d272c4fa58663b7c2522131758252fe61bbc75fa
Author: Iván Ávalos <avalos@disroot.org>
Date:   Tue, 20 May 2025 19:38:43 +0200

[wallet] allow scope/exchange selection in withdraw/p2p

bug 0009971

Diffstat:
Mwallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt | 5+++++
Mwallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Awallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt | 64++--------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt | 14++++++++++++++
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt | 76+++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt | 20++++++++++----------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt | 76+++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt | 14+++++++-------
Mwallet/src/main/java/net/taler/wallet/peer/PeerManager.kt | 19+++++++++++++++----
Mwallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt | 31++++++++++++++++++-------------
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt | 60+++++++++++++++++++++++++++---------------------------------
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt | 80++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mwallet/src/main/res/values/strings.xml | 3+++
14 files changed, 555 insertions(+), 218 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt @@ -147,6 +147,11 @@ class BalanceManager( } ?: emptyList() @UiThread + fun getScopes() = balances.value?.map { balanceItem -> + balanceItem.scopeInfo + } ?: emptyList() + + @UiThread fun hasSufficientBalance(amount: Amount): Boolean { balances.value?.forEach { balanceItem -> if (balanceItem.currency == amount.currency) { diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt @@ -17,22 +17,28 @@ package net.taler.wallet.compose import android.annotation.SuppressLint +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -45,6 +51,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.utf16CodePoint import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.InternalTextApi import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.BackspaceCommand @@ -60,8 +67,12 @@ import androidx.compose.ui.text.input.TextInputService import androidx.compose.ui.text.input.TextInputSession import androidx.compose.ui.unit.dp import net.taler.common.Amount -import net.taler.wallet.deposit.CurrencyDropdown +import net.taler.wallet.R +@Deprecated( + message = "Use AmountScopeField for scopeInfo support", + replaceWith = ReplaceWith("AmountScopeField"), +) @Composable fun AmountCurrencyField( modifier: Modifier = Modifier, @@ -124,6 +135,59 @@ fun AmountCurrencyField( } @Composable +private fun CurrencyDropdown( + currencies: List<String>, + onCurrencyChanged: (String) -> Unit, + modifier: Modifier = Modifier, + initialCurrency: String? = null, + readOnly: Boolean = false, +) { + val initialIndex = currencies.indexOf(initialCurrency).let { if (it < 0) 0 else it } + var selectedIndex by remember { mutableIntStateOf(initialIndex) } + var expanded by remember { mutableStateOf(false) } + Box( + modifier = modifier, + ) { + OutlinedTextField( + modifier = Modifier + .clickable(onClick = { if (!readOnly) expanded = true }) + .fillMaxWidth(), + value = currencies.getOrNull(selectedIndex) + ?: initialCurrency // wallet is empty or currency is new + ?: error("no currency available"), + onValueChange = { }, + readOnly = true, + enabled = false, + textStyle = LocalTextStyle.current.copy( // show text as if not disabled + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + singleLine = true, + label = { + Text(stringResource(R.string.currency)) + } + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier, + ) { + currencies.forEachIndexed { index, s -> + DropdownMenuItem( + text = { + Text(text = s) + }, + onClick = { + selectedIndex = index + onCurrencyChanged(currencies[index]) + expanded = false + } + ) + } + } + } +} + +@Composable private fun AmountInputShortcuts( amounts: List<Amount>, onSelected: (amount: Amount) -> Unit, @@ -145,7 +209,7 @@ private fun AmountInputShortcuts( } @Composable -private fun AmountInputFieldBase( +internal fun AmountInputFieldBase( amount: Amount, onAmountChanged: (amount: Amount) -> Unit, modifier: Modifier, diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountScopeField.kt @@ -0,0 +1,242 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.R +import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.cleanExchange + +data class AmountScope( + val amount: Amount, + val scope: ScopeInfo, +) + +@Composable +fun AmountScopeField( + modifier: Modifier = Modifier, + amount: AmountScope, + editableScope: Boolean = true, + scopes: List<ScopeInfo>, + onAmountChanged: (amount: AmountScope) -> Unit, + label: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + readOnly: Boolean = false, + enabled: Boolean = true, + showShortcuts: Boolean = false, + onShortcutSelected: ((amount: AmountScope) -> Unit)? = null, +) { + Column(modifier) { + if (editableScope) { + ScopeDropdown( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + scopes = scopes, + onScopeChanged = { scope -> + onAmountChanged(amount.copy( + scope = scope, + amount = Amount.zero(scope.currency), + )) + }, + initialScope = amount.scope, + readOnly = readOnly || !enabled, + ) + } + + AmountInputFieldBase( + modifier = Modifier + .fillMaxWidth(), + amount = amount.amount, + onAmountChanged = { + onAmountChanged(amount.copy(amount = it)) + }, + label = label, + isError = isError, + supportingText = supportingText, + readOnly = readOnly, + enabled = enabled, + showSymbol = true, + ) + + if (showShortcuts) { + val currency = amount.amount.currency + AmountInputShortcuts( + // TODO: currency-appropriate presets + amounts = listOf( + Amount.fromString(currency, "50").withSpec(amount.amount.spec), + Amount.fromString(currency, "25").withSpec(amount.amount.spec), + Amount.fromString(currency, "10").withSpec(amount.amount.spec), + Amount.fromString(currency, "5").withSpec(amount.amount.spec), + ), + onSelected = { shortcut -> + onShortcutSelected?.let { + it(amount.copy(amount = shortcut)) + } + }, + ) + } + } +} + +@Composable +fun ScopeDropdown( + scopes: List<ScopeInfo>, + onScopeChanged: (ScopeInfo) -> Unit, + modifier: Modifier = Modifier, + initialScope: ScopeInfo? = null, + readOnly: Boolean = false, +) { + val initialIndex = scopes.indexOf(initialScope).let { if (it < 0) 0 else it } + var selectedIndex by remember { mutableIntStateOf(initialIndex) } + var expanded by remember { mutableStateOf(false) } + Box( + modifier = modifier, + ) { + val scope = scopes.getOrNull(selectedIndex) + ?: initialScope + ?: error("no scope available") + + OutlinedTextField( + modifier = Modifier + .clickable(onClick = { if (!readOnly) expanded = true }) + .fillMaxWidth(), + value = when (scope) { + is ScopeInfo.Global -> scope.currency + is ScopeInfo.Exchange -> cleanExchange(scope.url) + is ScopeInfo.Auditor -> cleanExchange(scope.url) + }, + prefix = { Text( + modifier = Modifier.padding(end = 6.dp), + text = stringResource(R.string.currency_via) + ) }, + onValueChange = { }, + readOnly = true, + enabled = false, + textStyle = LocalTextStyle.current.copy( // show text as if not disabled + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + singleLine = true, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier, + ) { + scopes.forEachIndexed { index, s -> + DropdownMenuItem( + text = { + Text(text = when (s) { + is ScopeInfo.Global -> stringResource(R.string.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 + } + ) + } + } + } +} + +@Composable +private fun AmountInputShortcuts( + amounts: List<Amount>, + onSelected: (amount: Amount) -> Unit, +) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + maxItemsInEachRow = 2, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + amounts.forEach { + SelectionChip ( + selected = false, + label = { Text(it.toString()) }, + value = it, + onSelected = onSelected, + ) + } + } +} + +@Preview +@Composable +fun AmountInputFieldPreview() { + TalerSurface { + var amount by remember { + mutableStateOf(AmountScope( + amount = Amount.fromJSONString("KUDOS:10"), + scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + )) + } + AmountScopeField( + amount = amount, + editableScope = true, + scopes = listOf( + ScopeInfo.Global("CHF"), + ScopeInfo.Exchange("KUDOS", url = "https://exchange.demo.taler.net/"), + ScopeInfo.Auditor("TESTKUDOS", url = "https://auditor.test.taler.net/"), + ), + onAmountChanged = { amount = it }, + label = { Text("Amount to withdraw") }, + isError = false, + readOnly = false, + enabled = true, + showShortcuts = true, + onShortcutSelected = { amount = it }, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt @@ -16,30 +16,22 @@ package net.taler.wallet.deposit -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box 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.Button -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.LocalTextStyle 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.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -63,6 +55,7 @@ import net.taler.wallet.MainViewModel import net.taler.wallet.R import net.taler.wallet.compose.AmountCurrencyField import net.taler.wallet.compose.TalerSurface +import androidx.core.net.toUri class PayToUriFragment : Fragment() { private val model: MainViewModel by activityViewModels() @@ -75,7 +68,7 @@ class PayToUriFragment : Fragment() { savedInstanceState: Bundle?, ): View { val uri = arguments?.getString("uri") ?: error("no amount passed") - val u = Uri.parse(uri) + val u = uri.toUri() val receiverName = u.getQueryParameter("receiver-name") ?.replace('+', ' ') ?: "" val iban = u.pathSegments.last() ?: "" @@ -177,59 +170,6 @@ private fun PayToComposable( } } -@Composable -fun CurrencyDropdown( - currencies: List<String>, - onCurrencyChanged: (String) -> Unit, - modifier: Modifier = Modifier, - initialCurrency: String? = null, - readOnly: Boolean = false, -) { - val initialIndex = currencies.indexOf(initialCurrency).let { if (it < 0) 0 else it } - var selectedIndex by remember { mutableIntStateOf(initialIndex) } - var expanded by remember { mutableStateOf(false) } - Box( - modifier = modifier, - ) { - OutlinedTextField( - modifier = Modifier - .clickable(onClick = { if (!readOnly) expanded = true }) - .fillMaxWidth(), - value = currencies.getOrNull(selectedIndex) - ?: initialCurrency // wallet is empty or currency is new - ?: error("no currency available"), - onValueChange = { }, - readOnly = true, - enabled = false, - textStyle = LocalTextStyle.current.copy( // show text as if not disabled - color = MaterialTheme.colorScheme.onSurfaceVariant - ), - singleLine = true, - label = { - Text(stringResource(R.string.currency)) - } - ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier, - ) { - currencies.forEachIndexed { index, s -> - DropdownMenuItem( - text = { - Text(text = s) - }, - onClick = { - selectedIndex = index - onCurrencyChanged(currencies[index]) - expanded = false - } - ) - } - } - } -} - @Preview @Composable fun PreviewPayToComposable() { diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -31,6 +31,7 @@ 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.withdraw.TosResponse @Serializable @@ -148,6 +149,19 @@ class ExchangeManager( } @WorkerThread + suspend fun findExchange(scope: ScopeInfo): ExchangeItem? { + var exchange: ExchangeItem? = null + api.request( + operation = "listExchanges", + serializer = ExchangeListResponse.serializer() + ).onSuccess { exchangeListResponse -> + // just pick the first for now + exchange = exchangeListResponse.exchanges.find { it.scopeInfo == scope } + } + return exchange + } + + @WorkerThread suspend fun findExchangeByUrl(exchangeUrl: String): ExchangeItem? { var exchange: ExchangeItem? = null api.request("getExchangeEntryByUrl", ExchangeItem.serializer()) { diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -53,8 +53,10 @@ import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.cleanExchange -import net.taler.wallet.compose.AmountCurrencyField +import net.taler.wallet.compose.AmountScope +import net.taler.wallet.compose.AmountScopeField import net.taler.wallet.compose.TalerSurface import net.taler.wallet.exchanges.ExchangeTosStatus import net.taler.wallet.transactions.TransactionInfoComposable @@ -64,19 +66,19 @@ import kotlin.random.Random @Composable fun OutgoingPullComposable( state: OutgoingState, - defaultCurrency: String?, - currencies: List<String>, - getCurrencySpec: (currency: String) -> CurrencySpecification?, - checkPeerPullCredit: suspend (amount: Amount) -> CheckPeerPullCreditResult?, - onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, + defaultScope: ScopeInfo?, + scopes: List<ScopeInfo>, + getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, + checkPeerPullCredit: suspend (amount: AmountScope) -> CheckPeerPullCreditResult?, + onCreateInvoice: (amount: AmountScope, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, onTosAccept: (exchangeBaseUrl: String) -> Unit, onClose: () -> Unit, ) { when(state) { is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> PeerCreatingComposable() is OutgoingIntro, is OutgoingChecked -> OutgoingPullIntroComposable( - defaultCurrency = defaultCurrency, - currencies = currencies, + defaultScope = defaultScope, + scopes = scopes, getCurrencySpec = getCurrencySpec, checkPeerPullCredit = checkPeerPullCredit, onCreateInvoice = onCreateInvoice, @@ -102,11 +104,11 @@ fun PeerCreatingComposable() { @Composable fun OutgoingPullIntroComposable( - defaultCurrency: String?, - currencies: List<String>, - getCurrencySpec: (currency: String) -> CurrencySpecification?, - checkPeerPullCredit: suspend (amount: Amount) -> CheckPeerPullCreditResult?, - onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, + defaultScope: ScopeInfo?, + scopes: List<ScopeInfo>, + getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, + checkPeerPullCredit: suspend (amount: AmountScope) -> CheckPeerPullCreditResult?, + onCreateInvoice: (amount: AmountScope, subject: String, hours: Long, exchangeBaseUrl: String) -> Unit, onTosAccept: (exchangeBaseUrl: String) -> Unit, ) { val scrollState = rememberScrollState() @@ -118,8 +120,12 @@ fun OutgoingPullIntroComposable( horizontalAlignment = CenterHorizontally, ) { var subject by rememberSaveable { mutableStateOf("") } - var amount by remember { mutableStateOf(Amount.zero(defaultCurrency ?: currencies[0])) } - val selectedSpec = remember(amount.currency) { getCurrencySpec(amount.currency) } + var amount by remember { + val scope = defaultScope ?: scopes[0] + val currency = scope.currency + mutableStateOf(AmountScope(Amount.zero(currency), scope)) + } + val selectedSpec = remember(amount.scope) { getCurrencySpec(amount.scope) } var checkResult by remember { mutableStateOf<CheckPeerPullCreditResult?>(null) } amount.useDebounce { @@ -130,15 +136,15 @@ fun OutgoingPullIntroComposable( checkResult = checkPeerPullCredit(amount) } - AmountCurrencyField( + AmountScopeField( modifier = Modifier .padding(bottom = 16.dp) .fillMaxWidth(), - amount = amount.withSpec(selectedSpec), - currencies = currencies, + amount = amount.copy(amount = amount.amount.withSpec(selectedSpec)), + scopes = scopes, readOnly = false, onAmountChanged = { amount = it }, - isError = amount.isZero(), + isError = amount.amount.isZero(), label = { Text(stringResource(R.string.amount_receive)) }, ) @@ -267,8 +273,12 @@ fun PeerPullComposableCreatingPreview() { TalerSurface { OutgoingPullComposable( state = OutgoingCreating, - defaultCurrency = "KUDOS", - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + scopes = listOf( + ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), + ScopeInfo.Global("CHF"), + ), getCurrencySpec = { null }, checkPeerPullCredit = { null }, onCreateInvoice = { _, _, _, _ -> }, @@ -284,8 +294,12 @@ fun PeerPullComposableCheckingPreview() { TalerSurface { OutgoingPullComposable( state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking, - defaultCurrency = "KUDOS", - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + scopes = listOf( + ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), + ScopeInfo.Global("CHF"), + ), getCurrencySpec = { null }, checkPeerPullCredit = { null }, onCreateInvoice = { _, _, _, _ -> }, @@ -303,8 +317,12 @@ fun PeerPullComposableCheckedPreview() { val amountEffective = Amount.fromString("TESTKUDOS", "42.23") OutgoingPullComposable( state = OutgoingChecked(amountRaw, amountEffective, "https://exchange.demo.taler.net/", ExchangeTosStatus.Accepted), - defaultCurrency = "KUDOS", - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + scopes = listOf( + ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), + ScopeInfo.Global("CHF"), + ), getCurrencySpec = { null }, checkPeerPullCredit = { null }, onCreateInvoice = { _, _, _, _ -> }, @@ -322,8 +340,12 @@ fun PeerPullComposableErrorPreview() { val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json)) OutgoingPullComposable( state = state, - defaultCurrency = "KUDOS", - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + scopes = listOf( + ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), + ScopeInfo.Global("CHF"), + ), getCurrencySpec = { null }, checkPeerPullCredit = { null }, onCreateInvoice = { _, _, _, _ -> }, diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -30,9 +30,9 @@ 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.AmountScope import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.showError @@ -58,14 +58,14 @@ class OutgoingPullFragment : Fragment() { state = state, onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice, onTosAccept = this@OutgoingPullFragment::onTosAccept, - defaultCurrency = selectedScope?.currency, - currencies = balanceManager.getCurrencies(), - getCurrencySpec = balanceManager::getSpecForCurrency, + defaultScope = selectedScope, + scopes = balanceManager.getScopes(), + getCurrencySpec = balanceManager::getSpecForScopeInfo, checkPeerPullCredit = { - // TODO: this should work with scopeInfo/exchangeBaseUrl - exchangeManager.findExchange(it.currency)?.let { ex -> - peerManager.checkPeerPullCredit(it, - exchangeBaseUrl = ex.exchangeBaseUrl) + exchangeManager.findExchange(it.scope)?.let { ex -> + peerManager.checkPeerPullCredit(it.amount, + exchangeBaseUrl = ex.exchangeBaseUrl, + scopeInfo = it.scope) } }, onClose = { @@ -118,7 +118,7 @@ class OutgoingPullFragment : Fragment() { findNavController().navigate(R.id.action_global_reviewExchangeTos, bundle) } - private fun onCreateInvoice(amount: Amount, summary: String, hours: Long, exchangeBaseUrl: String) { - peerManager.initiatePeerPullCredit(amount, summary, hours, exchangeBaseUrl) + private fun onCreateInvoice(amount: AmountScope, summary: String, hours: Long, exchangeBaseUrl: String) { + peerManager.initiatePeerPullCredit(amount.amount, summary, hours, exchangeBaseUrl) } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -47,8 +47,10 @@ import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.cleanExchange -import net.taler.wallet.compose.AmountCurrencyField +import net.taler.wallet.compose.AmountScope +import net.taler.wallet.compose.AmountScopeField import net.taler.wallet.compose.TalerSurface import net.taler.wallet.exchanges.ExchangeTosStatus import net.taler.wallet.payment.stringResId @@ -62,18 +64,18 @@ import kotlin.random.Random @Composable fun OutgoingPushComposable( state: OutgoingState, - defaultCurrency: String?, - currencies: List<String>, - getCurrencySpec: (currency: String) -> CurrencySpecification?, - getFees: suspend (amount: Amount) -> CheckFeeResult?, - onSend: (amount: Amount, summary: String, hours: Long) -> Unit, + defaultScope: ScopeInfo?, + scopes: List<ScopeInfo>, + getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, + getFees: suspend (amount: AmountScope) -> CheckFeeResult?, + onSend: (amount: AmountScope, summary: String, hours: Long) -> Unit, onClose: () -> Unit, ) { when(state) { is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> PeerCreatingComposable() is OutgoingIntro, is OutgoingChecked -> OutgoingPushIntroComposable( - defaultCurrency = defaultCurrency, - currencies = currencies, + defaultScope = defaultScope, + scopes = scopes, getCurrencySpec = getCurrencySpec, getFees = getFees, onSend = onSend, @@ -84,11 +86,11 @@ fun OutgoingPushComposable( @Composable fun OutgoingPushIntroComposable( - defaultCurrency: String?, - currencies: List<String>, - getCurrencySpec: (currency: String) -> CurrencySpecification?, - getFees: suspend (amount: Amount) -> CheckFeeResult?, - onSend: (amount: Amount, summary: String, hours: Long) -> Unit, + defaultScope: ScopeInfo?, + scopes: List<ScopeInfo>, + getCurrencySpec: (scope: ScopeInfo) -> CurrencySpecification?, + getFees: suspend (amount: AmountScope) -> CheckFeeResult?, + onSend: (amount: AmountScope, summary: String, hours: Long) -> Unit, ) { val scrollState = rememberScrollState() Column( @@ -98,8 +100,12 @@ fun OutgoingPushIntroComposable( .verticalScroll(scrollState), horizontalAlignment = CenterHorizontally, ) { - var amount by remember { mutableStateOf(Amount.zero(defaultCurrency ?: currencies[0])) } - val selectedSpec = remember(amount.currency) { getCurrencySpec(amount.currency) } + var amount by remember { + val scope = defaultScope ?: scopes[0] + val currency = scope.currency + mutableStateOf(AmountScope(Amount.zero(currency), scope)) + } + val selectedSpec = remember(amount.scope) { getCurrencySpec(amount.scope) } var feeResult by remember { mutableStateOf<CheckFeeResult>(None()) } amount.useDebounce { @@ -133,14 +139,14 @@ fun OutgoingPushIntroComposable( } } - AmountCurrencyField( + AmountScopeField( modifier = Modifier.fillMaxWidth(), - amount = amount.withSpec(selectedSpec), - currencies = currencies, + amount = amount.copy(amount = amount.amount.withSpec(selectedSpec)), + scopes = scopes, readOnly = false, onAmountChanged = { amount = it }, label = { Text(stringResource(R.string.amount_send)) }, - isError = amount.isZero() || feeResult is InsufficientBalance, + isError = amount.amount.isZero() || feeResult is InsufficientBalance, supportingText = { when (val res = feeResult) { is Success -> if (res.amountEffective > res.amountRaw) { @@ -235,8 +241,12 @@ fun PeerPushComposableCreatingPreview() { TalerSurface { OutgoingPushComposable( state = OutgoingCreating, - defaultCurrency = "KUDOS", - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + scopes = listOf( + ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), + ScopeInfo.Global("CHF"), + ), getCurrencySpec = { null }, getFees = { Success( amountEffective = Amount.fromJSONString("KUDOS:10"), @@ -256,8 +266,12 @@ fun PeerPushComposableCheckingPreview() { val state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking OutgoingPushComposable( state = state, - defaultCurrency = "KUDOS", - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + scopes = listOf( + ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), + ScopeInfo.Global("CHF"), + ), getCurrencySpec = { null }, getFees = { Success( amountEffective = Amount.fromJSONString("KUDOS:10"), @@ -280,9 +294,13 @@ fun PeerPushComposableCheckedPreview() { val state = OutgoingChecked(amountRaw, amountEffective, "https://exchange.demo.taler.net", ExchangeTosStatus.Accepted) OutgoingPushComposable( state = state, - defaultCurrency = "KUDOS", - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), getCurrencySpec = { null }, + defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + scopes = listOf( + ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), + ScopeInfo.Global("CHF"), + ), getFees = { Success( amountEffective = Amount.fromJSONString("KUDOS:10"), amountRaw = Amount.fromJSONString("KUDOS:12"), @@ -303,8 +321,12 @@ fun PeerPushComposableErrorPreview() { val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json)) OutgoingPushComposable( state = state, - defaultCurrency = "KUDOS", - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + defaultScope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + scopes = listOf( + ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), + ScopeInfo.Exchange("TESTKUDOS", "https://exchange.test.taler.net/"), + ScopeInfo.Global("CHF"), + ), getCurrencySpec = { null }, getFees = { Success( amountEffective = Amount.fromJSONString("KUDOS:10"), diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt @@ -31,9 +31,9 @@ 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.AmountScope import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.showError @@ -67,10 +67,10 @@ class OutgoingPushFragment : Fragment() { val selectedScope by transactionManager.selectedScope.collectAsStateLifecycleAware() OutgoingPushComposable( state = state, - defaultCurrency = selectedScope?.currency, - currencies = balanceManager.getCurrencies(), - getCurrencySpec = balanceManager::getSpecForCurrency, - getFees = peerManager::checkPeerPushFees, + defaultScope = selectedScope, + scopes = balanceManager.getScopes(), + getCurrencySpec = balanceManager::getSpecForScopeInfo, + getFees = { peerManager.checkPeerPushFees(it.amount, restrictScope = it.scope) }, onSend = this@OutgoingPushFragment::onSend, onClose = { findNavController().navigate(R.id.action_nav_peer_push_to_nav_main) @@ -115,7 +115,7 @@ class OutgoingPushFragment : Fragment() { if (!requireActivity().isChangingConfigurations) peerManager.resetPushPayment() } - private fun onSend(amount: Amount, summary: String, hours: Long) { - peerManager.initiatePeerPushDebit(amount, summary, hours) + private fun onSend(amount: AmountScope, summary: String, hours: Long) { + peerManager.initiatePeerPushDebit(amount.amount, summary, hours, restrictScope = amount.scope) } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -147,14 +147,19 @@ class PeerManager( _outgoingPullState.value = OutgoingIntro } - suspend fun checkPeerPushFees(amount: Amount, exchangeBaseUrl: String? = null): CheckFeeResult { - val max = getMaxPeerPushDebitAmount(amount.currency, exchangeBaseUrl) + suspend fun checkPeerPushFees( + amount: Amount, + exchangeBaseUrl: String? = null, + restrictScope: ScopeInfo? = null, + ): CheckFeeResult { + val max = getMaxPeerPushDebitAmount(amount.currency, exchangeBaseUrl, restrictScope = restrictScope) var response: CheckFeeResult = CheckFeeResult.None( maxDepositAmountEffective = max?.effectiveAmount, maxDepositAmountRaw = max?.rawAmount, ) api.request("checkPeerPushDebitV2", CheckPeerPushDebitResponse.serializer()) { exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } + restrictScope?.let { put("restrictScope", JSONObject(BackendManager.json.encodeToString(it))) } put("amount", amount.toJSONString()) }.onSuccess { res -> response = when (val r = res) { @@ -189,7 +194,7 @@ class PeerManager( var response: GetMaxPeerPushDebitAmountResponse? = null api.request("getMaxPeerPushDebitAmount", GetMaxPeerPushDebitAmountResponse.serializer()) { exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } - restrictScope?.let { put("restrictScope", it) } + restrictScope?.let { put("restrictScope", JSONObject(BackendManager.json.encodeToString(it))) } put("currency", currency) }.onError { error -> Log.e(TAG, "got getMaxPeerPushDebitAmount error result $error") @@ -200,11 +205,17 @@ class PeerManager( return response } - fun initiatePeerPushDebit(amount: Amount, summary: String, expirationHours: Long) { + fun initiatePeerPushDebit( + amount: Amount, + summary: String, + expirationHours: Long, + restrictScope: ScopeInfo? = null, + ) { _outgoingPushState.value = OutgoingCreating scope.launch(Dispatchers.IO) { val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours)) api.request("initiatePeerPushDebit", InitiatePeerPushDebitResponse.serializer()) { + restrictScope?.let { put("restrictScope", JSONObject(BackendManager.json.encodeToString(it))) } put("amount", amount.toJSONString()) put("partialContractTerms", JSONObject().apply { put("amount", amount.toJSONString()) diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -50,7 +50,9 @@ import net.taler.common.Amount import net.taler.common.EventObserver import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware @@ -88,8 +90,9 @@ class PromptWithdrawFragment: Fragment() { val withdrawExchangeUri = arguments?.getString("withdrawExchangeUri") val exchangeBaseUrl = arguments?.getString("exchangeBaseUrl") val amount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } + val scope: ScopeInfo? = arguments?.getString("scopeInfo")?.let { BackendManager.json.decodeFromString(it) } editableCurrency = arguments?.getBoolean("editableCurrency") ?: true - val currencies = balanceManager.getCurrencies() + val scopes = balanceManager.getScopes() setContent { val status by withdrawManager.withdrawStatus.collectAsStateLifecycleAware() @@ -101,10 +104,10 @@ class PromptWithdrawFragment: Fragment() { ?: MutableStateFlow(null) }.collectAsStateLifecycleAware(null) - val defaultCurrency = amount?.currency - ?: status.currency - ?: transactionManager.selectedScope.value?.currency - ?: currencies.firstOrNull() + val defaultScope = scope + ?: status.scopeInfo + ?: transactionManager.selectedScope.value + ?: scopes.firstOrNull() LaunchedEffect(status.status) { if (status.status == None) { @@ -114,10 +117,11 @@ class PromptWithdrawFragment: Fragment() { } else if (withdrawExchangeUri != null) { // get withdrawal details for taler://withdraw-exchange URI withdrawManager.prepareManualWithdrawal(withdrawExchangeUri) - } else if (defaultCurrency != null && !status.isCashAcceptor) { + } else if (defaultScope != null && !status.isCashAcceptor) { // get withdrawal details for available data withdrawManager.getWithdrawalDetails( - amount = amount ?: Amount.zero(defaultCurrency), + amount = amount ?: Amount.zero(defaultScope.currency), + scopeInfo = scope ?: defaultScope, exchangeBaseUrl = exchangeBaseUrl, loading = true, ) @@ -145,7 +149,7 @@ class PromptWithdrawFragment: Fragment() { TalerSurface { status.let { s -> - if (defaultCurrency == null) { + if (defaultScope == null) { LoadingScreen() return@let } @@ -158,18 +162,19 @@ class PromptWithdrawFragment: Fragment() { WithdrawalShowInfo( status = s, devMode = devMode ?: false, - defaultCurrency = defaultCurrency, - editableCurrency = editableCurrency, - currencies = currencies, + defaultScope = defaultScope, + editableScope = editableCurrency, + scopes = scopes, spec = currencySpec, onSelectExchange = { selectExchange() }, - onSelectAmount = { amount -> + onSelectAmount = { amount, scope -> withdrawManager.getWithdrawalDetails( amount = amount, + scopeInfo = scope, // only show loading screen when switching currencies - loading = amount.currency != status.currency, + loading = scope != status.scopeInfo, ) }, onTosReview = { diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -63,6 +63,7 @@ data class WithdrawStatus( // received details val currency: String? = null, + val scopeInfo: ScopeInfo? = null, val uriInfo: WithdrawalDetailsForUri? = null, val amountInfo: WithdrawalDetailsForAmount? = null, @@ -328,6 +329,7 @@ class WithdrawManager( fun getWithdrawalDetails( amount: Amount? = null, + scopeInfo: ScopeInfo? = null, exchangeBaseUrl: String? = null, loading: Boolean = true, ) = scope.launch { @@ -338,45 +340,36 @@ class WithdrawManager( val ex: ExchangeItem val am: Amount? - // TODO: use scopeInfo instead of currency - // use cases: - if (amount != null && exchangeBaseUrl != null) { - // 1. user sets both to null state - // => they are processed as-is - ex = exchangeManager.findExchangeByUrl(exchangeBaseUrl) + 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 = amount + am = ex.currency?.let { amount.copy(currency = it) } + ?: error("could not resolve currency") } else if (amount != null) { - // 2a user updates amount - // => amount is updated - // => exchange URL is recycled (unless currency changes) - // 2b. user sets amount to null state - // => exchange URL is calculated from amount - ex = if (status.exchangeBaseUrl != null - && status.currency == amount.currency) { - exchangeManager.findExchangeByUrl(status.exchangeBaseUrl) - ?: error("could not resolve exchange") - } else { - exchangeManager.findExchange(amount.currency) - ?: error("could not resolve exchange") - } + // 2. caller only provides amount + // => amount is updated + // => exchange URL is kept + ex = status.exchangeBaseUrl?.let { exchangeManager.findExchangeByUrl(it) } + ?: status.scopeInfo?.let { exchangeManager.findExchange(it) } + ?: exchangeManager.findExchange(amount.currency) + ?: error("could not resolve exchange") am = amount } else if (exchangeBaseUrl != null) { - // 3a. user updates exchange URL - // => amount is recycled (unless currency changes) - // => exchangeURL is updated - // 3b. user sets exchange URL to null state - // => amount is calculated from exchange URL + // 3. caller only provides exchange URL ex = exchangeManager.findExchangeByUrl(exchangeBaseUrl) ?: error("could not resolve exchange") - am = if (status.amountInfo?.amountRaw != null - && status.currency == ex.currency) { - status.amountInfo.amountRaw - } else { - ex.currency - ?.let { Amount.zero(it) } - ?: error("could not resolve currency") - } + am = status.amountInfo?.amountRaw + ?: 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 + ?: ex.currency?.let { Amount.zero(ex.currency) } + ?: error("could not resolve currency") } else { error("no parameters specified") } @@ -398,6 +391,7 @@ class WithdrawManager( exchangeBaseUrl = ex.exchangeBaseUrl, amountInfo = details, currency = details.amountRaw.currency, + scopeInfo = details.scopeInfo, ) } } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt @@ -53,7 +53,8 @@ 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.AmountCurrencyField +import net.taler.wallet.compose.AmountScope +import net.taler.wallet.compose.AmountScopeField import net.taler.wallet.compose.BottomButtonBox import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.WarningLabel @@ -73,41 +74,43 @@ import net.taler.wallet.withdraw.WithdrawalOperationStatusFlag.Pending fun WithdrawalShowInfo( status: WithdrawStatus, devMode: Boolean, - defaultCurrency: String, - editableCurrency: Boolean, - currencies: List<String>, + defaultScope: ScopeInfo, + editableScope: Boolean, + scopes: List<ScopeInfo>, spec: CurrencySpecification?, - onSelectAmount: (amount: Amount) -> Unit, + onSelectAmount: (amount: Amount, scope: ScopeInfo) -> Unit, onSelectExchange: () -> Unit, onTosReview: () -> Unit, onConfirm: (age: Int?) -> Unit, ) { val defaultAmount = status.amountInfo?.amountRaw ?: status.uriInfo?.amount - ?: Amount.zero(defaultCurrency) + ?: Amount.zero(defaultScope.currency) val maxAmount = status.uriInfo?.maxAmount val editableAmount = status.uriInfo?.editableAmount ?: true - val wireFee = status.uriInfo?.wireFee ?: Amount.zero(defaultCurrency) + val wireFee = status.uriInfo?.wireFee ?: Amount.zero(defaultScope.currency) val exchange = status.exchangeBaseUrl val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList() val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current - var startup by remember { mutableStateOf(true) } - var selectedAmount by remember { mutableStateOf(defaultAmount) } + var selectedAmount by remember { mutableStateOf(AmountScope(defaultAmount, defaultScope)) } var selectedAge by remember { mutableStateOf<Int?>(null) } - var error by remember { mutableStateOf(false) } val scrollState = rememberScrollState() val insufficientBalance = remember(selectedAmount, maxAmount) { - maxAmount == null || selectedAmount > maxAmount + maxAmount == null || selectedAmount.amount > maxAmount } + var startup by remember { mutableStateOf(true) } selectedAmount.useDebounce { if (startup) { // do not fire at startup startup = false } else { - onSelectAmount(it) + onSelectAmount( + selectedAmount.amount, + selectedAmount.scope, + ) } } @@ -136,24 +139,28 @@ fun WithdrawalShowInfo( .fillMaxWidth(), ) } else if (editableAmount) { - AmountCurrencyField( + AmountScopeField( modifier = Modifier - .padding(16.dp) + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) .fillMaxWidth() .focusRequester(focusRequester), - amount = selectedAmount.withSpec(spec), - currencies = currencies, - editableCurrency = editableCurrency, + amount = selectedAmount.copy( + amount = selectedAmount.amount.withSpec(spec)), + scopes = scopes, + editableScope = editableScope, onAmountChanged = { amount -> - selectedAmount = if (amount.currency != status.currency) { + selectedAmount = if (amount.scope != status.scopeInfo) { // if amount changes, reset to zero! - Amount.zero(amount.currency) + amount.copy(amount = Amount.zero(amount.scope.currency)) } else { amount } }, label = { Text(stringResource(R.string.amount_withdraw)) }, - isError = selectedAmount.isZero() || maxAmount != null && selectedAmount > maxAmount, + isError = selectedAmount.amount.isZero() + || maxAmount != null + && selectedAmount.amount > maxAmount, supportingText = { if (insufficientBalance && maxAmount != null) { Text(stringResource(R.string.amount_excess, maxAmount)) @@ -175,7 +182,7 @@ fun WithdrawalShowInfo( } else { stringResource(R.string.amount_chosen) }, - amount = selectedAmount, + amount = selectedAmount.amount, amountType = if (wireFee.isZero()) { AmountType.Positive } else { @@ -198,7 +205,7 @@ fun WithdrawalShowInfo( TransactionAmountComposable( label = stringResource(R.string.amount_total), - amount = selectedAmount + wireFee, + amount = selectedAmount.amount + wireFee, amountType = AmountType.Positive, ) } @@ -270,11 +277,10 @@ fun WithdrawalShowInfo( Button( modifier = Modifier .systemBarsPaddingBottom(), - enabled = !error - && status.status != Updating + enabled = status.status != Updating && (status.isCashAcceptor || status.status == TosReviewRequired - || !selectedAmount.isZero()), + || !selectedAmount.amount.isZero()), onClick = { keyboardController?.hide() if (status.status == TosReviewRequired) { @@ -346,12 +352,16 @@ fun WithdrawalShowInfoUpdatingPreview() { WithdrawalShowInfo( status = buildPreviewWithdrawStatus(Updating), devMode = true, - defaultCurrency = "KUDOS", - editableCurrency = true, - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + defaultScope = 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 = {}, + onSelectAmount = { _, _ -> }, onTosReview = {}, onConfirm = {}, ) @@ -365,12 +375,16 @@ fun WithdrawalShowInfoTosReviewPreview() { WithdrawalShowInfo( status = buildPreviewWithdrawStatus(TosReviewRequired), devMode = true, - defaultCurrency = "KUDOS", - editableCurrency = true, - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), + defaultScope = 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 = {}, + onSelectAmount = { _, _ -> }, onTosReview = {}, onConfirm = {}, ) diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -56,6 +56,9 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="char_count">%1$d/%2$d</string> <string name="copy" tools:override="true">Copy</string> <string name="currency">Currency</string> + <!-- Currency name (provider) --> + <string name="currency_url">%1$s (%2$s)</string> + <string name="currency_via">via</string> <string name="edit">Edit</string> <string name="enter_uri">Enter taler:// URI</string> <string name="enter_uri_label">Enter URI</string>