taler-android

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

commit 7a414a7a9c112ab38839c773f579554bf7a63965
parent 67d766bbfd44ef5648faa1861359e8dd0c8e2b62
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 21 Nov 2024 17:21:42 +0100

[android] move deposit amount input to separate screen

Diffstat:
Mwallet/build.gradle | 6+++---
Mwallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt | 3+++
Awallet/src/main/java/net/taler/wallet/deposit/DepositAmountComposable.kt | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mwallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt | 10+++++++++-
Mwallet/src/main/java/net/taler/wallet/deposit/DepositState.kt | 16+++++-----------
Dwallet/src/main/java/net/taler/wallet/deposit/MakeBitcoinDepositComposable.kt | 164-------------------------------------------------------------------------------
Awallet/src/main/java/net/taler/wallet/deposit/MakeDepositBitcoin.kt | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt | 154++++++++++++++++---------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt | 3++-
Mwallet/src/main/res/values/strings.xml | 1+
11 files changed, 371 insertions(+), 329 deletions(-)

diff --git a/wallet/build.gradle b/wallet/build.gradle @@ -136,16 +136,16 @@ dependencies { implementation "androidx.browser:browser:1.8.0" // Compose - implementation platform('androidx.compose:compose-bom:2024.06.00') + implementation platform('androidx.compose:compose-bom:2024.11.00') implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material:material-icons-extended' implementation "androidx.compose.runtime:runtime-livedata" implementation "androidx.lifecycle:lifecycle-viewmodel-compose" implementation "com.google.accompanist:accompanist-themeadapter-material3:0.28.0" - implementation 'androidx.activity:activity-compose:1.9.1' + implementation 'androidx.activity:activity-compose:1.9.3' implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-viewbinding' - implementation "androidx.fragment:fragment-compose:1.8.4" + implementation "androidx.fragment:fragment-compose:1.8.5" debugImplementation 'androidx.compose.ui:ui-tooling' // Lists and Selection diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountInputFIeld.kt @@ -16,6 +16,7 @@ package net.taler.wallet.compose +import android.annotation.SuppressLint import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState @@ -106,6 +107,7 @@ private fun AmountInputFieldBase( readOnly: Boolean = false, enabled: Boolean = true, ) { + // TODO: use non-deprecated PlatformTextInputModifierNode instead val inputService = LocalTextInputService.current val interactionSource = remember { MutableInteractionSource() } val isFocused: Boolean by interactionSource.collectIsFocusedAsState() @@ -168,6 +170,7 @@ private fun AmountInputFieldBase( ) } +@SuppressLint("RestrictedApi") @OptIn(InternalTextApi::class) fun startSession( textInputService: TextInputService?, diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositAmountComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositAmountComposable.kt @@ -0,0 +1,185 @@ +/* + * 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.deposit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +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.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.R +import net.taler.wallet.compose.AmountCurrencyField +import net.taler.wallet.transactions.AmountType.Negative +import net.taler.wallet.transactions.AmountType.Positive +import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.useDebounce + +@Composable +fun DepositAmountComposable( + state: DepositState, + currency: String, + currencySpec: CurrencySpecification?, + checkDeposit: suspend (amount: Amount) -> CheckDepositResult, + onMakeDeposit: (amount: Amount) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .imePadding(), + horizontalAlignment = CenterHorizontally, + ) { + var checkResult by remember { mutableStateOf<CheckDepositResult>(CheckDepositResult.None()) } + var amount by remember { mutableStateOf(Amount.zero(currency)) } + + amount.useDebounce { + if (!amount.isZero()) { + checkResult = checkDeposit(amount) + } + } + + AnimatedVisibility(checkResult.maxDepositAmountEffective != null) { + checkResult.maxDepositAmountEffective?.let { + Text( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + text = stringResource( + R.string.amount_available_transfer, + it.withSpec(currencySpec), + ), + ) + } + } + + AmountCurrencyField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + amount = amount.withSpec(currencySpec), + onAmountChanged = { amount = it }, + editableCurrency = false, + currencies = listOf(), + isError = checkResult !is CheckDepositResult.Success, + label = { Text(stringResource(R.string.amount_deposit)) }, + supportingText = { + val res = checkResult + if (res is CheckDepositResult.InsufficientBalance && res.maxAmountEffective != null) { + Text( + stringResource( + R.string.payment_balance_insufficient_max, + res.maxAmountEffective.withSpec(currencySpec), + ) + ) + } + } + ) + + AnimatedVisibility(visible = checkResult is CheckDepositResult.Success) { + val res = checkResult as? CheckDepositResult.Success ?: return@AnimatedVisibility + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = CenterHorizontally, + ) { + val totalAmount = res.totalDepositCost + val effectiveAmount = res.effectiveDepositAmount + if (totalAmount > effectiveAmount) { + val fee = totalAmount - effectiveAmount + + TransactionAmountComposable( + label = stringResource(R.string.amount_fee), + amount = fee.withSpec(amount.spec), + amountType = Negative, + ) + } + + TransactionAmountComposable( + label = stringResource(R.string.amount_send), + amount = effectiveAmount.withSpec(amount.spec), + amountType = Positive, + ) + } + } + + AnimatedVisibility(visible = state is DepositState.Error) { + Text( + modifier = Modifier.padding(16.dp), + fontSize = 18.sp, + color = MaterialTheme.colorScheme.error, + text = (state as? DepositState.Error)?.error?.userFacingMsg ?: "", + ) + } + + val focusManager = LocalFocusManager.current + Button( + modifier = Modifier.padding(16.dp), + enabled = checkResult is CheckDepositResult.Success, + onClick = { + focusManager.clearFocus() + onMakeDeposit(amount) + }, + ) { + Text(stringResource(R.string.send_deposit_create_button)) + } + + BottomInsetsSpacer() + } +} + +@Preview +@Composable +fun DepositAmountComposablePreview() { + Surface { + val state = DepositState.AccountSelected("payto://", "KUDOS") + DepositAmountComposable( + state = state, + currency = "KUDOS", + currencySpec = null, + checkDeposit = { CheckDepositResult.Success( + totalDepositCost = Amount.fromJSONString("KUDOS:10"), + effectiveDepositAmount = Amount.fromJSONString("KUDOS:12"), + maxDepositAmountEffective = Amount.fromJSONString("KUDOS:12") + ) }, + onMakeDeposit = {}, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt @@ -20,17 +20,19 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.runBlocking import net.taler.common.Amount import net.taler.common.showError -import net.taler.wallet.CURRENCY_BTC import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.showError @@ -48,7 +50,6 @@ class DepositFragment : Fragment() { ): View { val presetAmount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } val scopeInfo = transactionManager.selectedScope.value - val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } val receiverName = arguments?.getString("receiverName") val iban = arguments?.getString("IBAN") @@ -60,30 +61,65 @@ class DepositFragment : Fragment() { return ComposeView(requireContext()).apply { setContent { TalerSurface { - val state = depositManager.depositState.collectAsStateLifecycleAware() + val state by depositManager.depositState.collectAsStateLifecycleAware() - // TODO: refactor Bitcoin as wire method - if (presetAmount?.currency == CURRENCY_BTC) MakeBitcoinDepositComposable( - state = state.value, - amount = presetAmount.withSpec(spec), - bitcoinAddress = null, - onMakeDeposit = { amount, bitcoinAddress -> - val paytoUri = getBitcoinPayto(bitcoinAddress) - depositManager.makeDeposit(amount, paytoUri) - }, - ) else MakeDepositComposable( - state = state.value, - defaultCurrency = scopeInfo?.currency, - currencies = balanceManager.getCurrencies(), - getCurrencySpec = { runBlocking { balanceManager.getSpecForCurrency(it) } }, - checkDeposit = { a, p -> depositManager.checkDepositFees(p, a) }, - getDepositWireTypes = depositManager::getDepositWireTypesForCurrency, - presetName = receiverName, - presetIban = iban, - validateIban = depositManager::validateIban, - onMakeDeposit = depositManager::makeDeposit, - onClose = { findNavController().popBackStack() }, - ) + BackHandler(state is DepositState.AccountSelected) { + depositManager.resetDepositState() + } + + when (val s = state) { + is DepositState.MakingDeposit, is DepositState.Success -> { + LoadingScreen() + } + + is DepositState.Error -> { + MakeDepositErrorComposable(s.error.userFacingMsg) { + findNavController().popBackStack() + } + } + + is DepositState.Start -> { + // TODO: refactor Bitcoin as wire method +// if (presetAmount?.currency == CURRENCY_BTC) MakeBitcoinDepositComposable( +// state = state, +// amount = presetAmount.withSpec(spec), +// bitcoinAddress = null, +// onMakeDeposit = { amount, bitcoinAddress -> +// val paytoUri = getBitcoinPayto(bitcoinAddress) +// depositManager.makeDeposit(amount, paytoUri) +// }, + MakeDepositComposable( + defaultCurrency = scopeInfo?.currency, + currencies = balanceManager.getCurrencies(), + getDepositWireTypes = depositManager::getDepositWireTypesForCurrency, + presetName = receiverName, + presetIban = iban, + validateIban = depositManager::validateIban, + onPaytoSelected = { paytoUri, currency -> + depositManager.selectAccount(paytoUri, currency) + }, + onClose = { + findNavController().popBackStack() + }, + ) + } + + is DepositState.AccountSelected -> { + DepositAmountComposable( + state = s, + currency = s.currency, + currencySpec = remember(s.currency) { + balanceManager.getSpecForCurrency(s.currency) + }, + checkDeposit = { a -> + depositManager.checkDepositFees(s.paytoUri, a) + }, + onMakeDeposit = { amount -> + depositManager.makeDeposit(amount, s.paytoUri) + }, + ) + } + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt @@ -54,6 +54,11 @@ class DepositManager( return u.pathSegments.size >= 1 } + @UiThread + fun selectAccount(paytoUri: String, currency: String) { + mDepositState.value = DepositState.AccountSelected(paytoUri, currency) + } + suspend fun checkDepositFees(paytoUri: String, amount: Amount): CheckDepositResult { val max = getMaxDepositAmount(amount.currency, paytoUri) var response: CheckDepositResult = CheckDepositResult.None( @@ -72,7 +77,7 @@ class DepositManager( maxDepositAmountEffective = max?.effectiveAmount, ) }.onError { error -> - Log.e(TAG, "Error prepareDeposit $error") + Log.e(TAG, "Error checkDeposit $error") if (error.code == WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE) { error.extra["insufficientBalanceDetails"]?.let { details -> val maxAmountRaw = details.jsonObject["balanceAvailable"]?.let { amount -> @@ -244,6 +249,9 @@ enum class WireType { @SerialName("x-taler-bank") TalerBank, + + @SerialName("bitcoin") + Bitcoin, } @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt @@ -16,22 +16,16 @@ package net.taler.wallet.deposit -import net.taler.common.Amount import net.taler.wallet.backend.TalerErrorInfo sealed class DepositState { - open val showFees: Boolean = false - open val totalDepositCost: Amount? = null - open val effectiveDepositAmount: Amount? = null - data object Start : DepositState() - data class FeesChecked( - override val totalDepositCost: Amount, - override val effectiveDepositAmount: Amount, - ) : DepositState() { - override val showFees = true - } + data class AccountSelected( + val paytoUri: String, + val currency: String, + ) : DepositState() + data object MakingDeposit : DepositState() data object Success : DepositState() diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeBitcoinDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeBitcoinDepositComposable.kt @@ -1,164 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2023 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.deposit - -import androidx.compose.animation.AnimatedVisibility -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.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.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import net.taler.common.Amount -import net.taler.wallet.CURRENCY_BTC -import net.taler.wallet.R -import net.taler.wallet.transactions.AmountType -import net.taler.wallet.transactions.TransactionAmountComposable - -@Composable -fun MakeBitcoinDepositComposable( - state: DepositState, - amount: Amount, - bitcoinAddress: String? = null, - onMakeDeposit: (Amount, String) -> Unit, -) { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - horizontalAlignment = CenterHorizontally, - ) { - var address by rememberSaveable { mutableStateOf(bitcoinAddress ?: "") } - val focusRequester = remember { FocusRequester() } - OutlinedTextField( - modifier = Modifier - .padding( - bottom = 16.dp, - start = 16.dp, - end = 16.dp, - ).focusRequester(focusRequester), - value = address, - singleLine = true, - enabled = !state.showFees, - onValueChange = { input -> - address = input - }, - isError = address.isBlank(), - label = { - Text( - stringResource(R.string.send_deposit_bitcoin_address), - color = if (address.isBlank()) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, - ) - } - ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - val amountTitle = if (state.effectiveDepositAmount == null) { - R.string.amount_chosen - } else R.string.amount_effective - TransactionAmountComposable( - label = stringResource(id = amountTitle), - amount = state.effectiveDepositAmount ?: amount, - amountType = AmountType.Positive, - ) - AnimatedVisibility(visible = state.showFees) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = CenterHorizontally, - ) { - val totalAmount = state.totalDepositCost ?: amount - val effectiveAmount = state.effectiveDepositAmount ?: Amount.zero(amount.currency) - if (totalAmount > effectiveAmount) { - val fee = totalAmount - effectiveAmount - TransactionAmountComposable( - label = stringResource(id = R.string.amount_fee), - amount = fee, - amountType = AmountType.Negative, - ) - } - TransactionAmountComposable( - label = stringResource(id = R.string.amount_send), - amount = totalAmount, - amountType = AmountType.Positive, - ) - } - } - AnimatedVisibility(visible = state is DepositState.Error) { - Text( - modifier = Modifier.padding(16.dp), - fontSize = 18.sp, - color = MaterialTheme.colorScheme.error, - text = (state as? DepositState.Error)?.error?.userFacingMsg ?: "", - ) - } - val focusManager = LocalFocusManager.current - Button( - modifier = Modifier.padding(16.dp), - enabled = address.isNotBlank(), - onClick = { - focusManager.clearFocus() - // TODO validate bitcoin address - onMakeDeposit(amount, address) - }, - ) { - Text(text = stringResource( - if (state.showFees) R.string.send_deposit_bitcoin_create_button - else R.string.send_deposit_check_fees_button - )) - } - } -} - -@Preview -@Composable -fun PreviewMakeBitcoinDepositComposable() { - Surface { - val state = DepositState.FeesChecked( - effectiveDepositAmount = Amount.fromString(CURRENCY_BTC, "42.00"), - totalDepositCost = Amount.fromString(CURRENCY_BTC, "42.23"), - ) - MakeBitcoinDepositComposable( - state = state, - amount = Amount.fromString(CURRENCY_BTC, "42.23")) { _, _ -> - } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositBitcoin.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositBitcoin.kt @@ -0,0 +1,69 @@ +/* + * 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.deposit + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import net.taler.wallet.R + +@Composable +fun MakeDepositBitcoin( + bitcoinAddress: String, + onFormEdited: (bitcoinAddress: String) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ).focusRequester(focusRequester), + value = bitcoinAddress, + singleLine = true, + onValueChange = { input -> + onFormEdited(input) + }, + isError = bitcoinAddress.isBlank(), + label = { + Text( + stringResource(R.string.send_deposit_bitcoin_address), + color = if (bitcoinAddress.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt @@ -16,7 +16,6 @@ package net.taler.wallet.deposit -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding @@ -24,7 +23,6 @@ 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.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Tab @@ -44,35 +42,24 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch -import net.taler.common.Amount -import net.taler.common.CurrencySpecification 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.compose.AmountCurrencyField import net.taler.wallet.compose.WarningLabel import net.taler.wallet.peer.OutgoingError import net.taler.wallet.peer.PeerErrorComposable -import net.taler.wallet.transactions.AmountType.Negative -import net.taler.wallet.transactions.AmountType.Positive -import net.taler.wallet.transactions.TransactionAmountComposable -import net.taler.wallet.useDebounce @Composable fun MakeDepositComposable( - state: DepositState, defaultCurrency: String?, currencies: List<String>, - getCurrencySpec: (currency: String) -> CurrencySpecification?, - checkDeposit: suspend (amount: Amount, paytoUri: String) -> CheckDepositResult, getDepositWireTypes: suspend (currency: String) -> GetDepositWireTypesForCurrencyResponse?, presetName: String? = null, presetIban: String? = null, validateIban: suspend (iban: String) -> Boolean, - onMakeDeposit: (Amount, String) -> Unit, + onPaytoSelected: (payto: String, currency: String) -> Unit, onClose: () -> Unit, ) { val scrollState = rememberScrollState() @@ -83,19 +70,15 @@ fun MakeDepositComposable( .imePadding(), horizontalAlignment = CenterHorizontally, ) { - // Amount/currency stuff - // TODO: use scopeInfo instead of currency! - var checkResult by remember { mutableStateOf<CheckDepositResult>(CheckDepositResult.None()) } - var amount by remember { mutableStateOf(Amount.zero(defaultCurrency ?: currencies[0])) } - val currencySpec = remember (amount) { getCurrencySpec(amount.currency) } - + // TODO: use scopeInfo instead of currency + var currency by remember { mutableStateOf(defaultCurrency ?: currencies[0]) } var depositWireTypes by remember { mutableStateOf<GetDepositWireTypesForCurrencyResponse?>(null) } val supportedWireTypes = remember(depositWireTypes) { depositWireTypes?.wireTypes ?: emptyList() } val talerBankHostnames = remember(depositWireTypes) { depositWireTypes?.wireTypeDetails?.flatMap { it.talerBankHostnames }?.distinct() ?: emptyList() } var selectedWireType by remember { mutableStateOf(supportedWireTypes.firstOrNull()) } - LaunchedEffect(amount.currency) { - depositWireTypes = getDepositWireTypes(amount.currency) + LaunchedEffect(currency) { + depositWireTypes = getDepositWireTypes(currency) } // payto:// stuff @@ -105,15 +88,17 @@ fun MakeDepositComposable( var talerName by rememberSaveable { mutableStateOf(presetName ?: "") } var talerHost by rememberSaveable { mutableStateOf(talerBankHostnames.firstOrNull() ?: "") } var talerAccount by rememberSaveable { mutableStateOf("") } + var bitcoinAddress by rememberSaveable { mutableStateOf("") } val paytoUri = when(selectedWireType) { WireType.IBAN -> getIbanPayto(ibanName, ibanIban) WireType.TalerBank -> getTalerPayto(talerName, talerHost, talerAccount) + WireType.Bitcoin -> getBitcoinPayto(bitcoinAddress) else -> null } // reset forms and selected wire type when switching currency - DisposableEffect(supportedWireTypes, amount.currency) { + DisposableEffect(supportedWireTypes, currency) { selectedWireType = supportedWireTypes.firstOrNull() formError = true ibanName = presetName ?: "" @@ -121,21 +106,10 @@ fun MakeDepositComposable( talerName = presetName ?: "" talerHost = talerBankHostnames.firstOrNull() ?: "" talerAccount = "" + bitcoinAddress = "" onDispose { } } - amount.useDebounce { - if (paytoUri != null && !formError) { - checkResult = checkDeposit(amount, paytoUri) - } - } - - paytoUri.useDebounce { - if (paytoUri != null && !formError) { - checkResult = checkDeposit(amount, paytoUri) - } - } - if (supportedWireTypes.isEmpty()) { return@Column MakeDepositErrorComposable( message = stringResource(R.string.send_deposit_no_methods_error), @@ -143,6 +117,15 @@ fun MakeDepositComposable( ) } + CurrencyDropdown( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + currencies = currencies, + onCurrencyChanged = { currency = it }, + initialCurrency = defaultCurrency, + ) + if (selectedWireType != null && supportedWireTypes.size > 1) { MakeDepositWireTypeChooser( supportedWireTypes = supportedWireTypes, @@ -194,95 +177,29 @@ fun MakeDepositComposable( } ) - else -> {} - } - - AnimatedVisibility(checkResult.maxDepositAmountEffective != null) { - checkResult.maxDepositAmountEffective?.let { - Text( - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - text = stringResource( - R.string.amount_available_transfer, - it.withSpec(currencySpec), - ), - ) - } - } - - AmountCurrencyField( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - amount = amount.withSpec(currencySpec), - onAmountChanged = { amount = it }, - editableCurrency = true, - currencies = currencies, - isError = checkResult !is CheckDepositResult.Success, - label = { Text(stringResource(R.string.amount_deposit)) }, - enabled = !formError, - supportingText = { - val res = checkResult - if (res is CheckDepositResult.InsufficientBalance && res.maxAmountEffective != null) { - Text(stringResource( - R.string.payment_balance_insufficient_max, - res.maxAmountEffective.withSpec(currencySpec), - )) + WireType.Bitcoin -> MakeDepositBitcoin( + bitcoinAddress = bitcoinAddress, + onFormEdited = { address -> + bitcoinAddress = address + formError = address.isBlank() } - } - ) - - AnimatedVisibility(visible = checkResult is CheckDepositResult.Success) { - val res = checkResult as? CheckDepositResult.Success ?: return@AnimatedVisibility - - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = CenterHorizontally, - ) { - val totalAmount = res.totalDepositCost - val effectiveAmount = res.effectiveDepositAmount - if (totalAmount > effectiveAmount) { - val fee = totalAmount - effectiveAmount - - TransactionAmountComposable( - label = stringResource(R.string.amount_fee), - amount = fee.withSpec(amount.spec), - amountType = Negative, - ) - } - - TransactionAmountComposable( - label = stringResource(R.string.amount_send), - amount = effectiveAmount.withSpec(amount.spec), - amountType = Positive, - ) - } - } - - AnimatedVisibility(visible = state is DepositState.Error) { - Text( - modifier = Modifier.padding(16.dp), - fontSize = 18.sp, - color = MaterialTheme.colorScheme.error, - text = (state as? DepositState.Error)?.error?.userFacingMsg ?: "", ) + + else -> {} } val focusManager = LocalFocusManager.current Button( modifier = Modifier.padding(16.dp), - enabled = checkResult is CheckDepositResult.Success && !formError, + enabled = !formError, onClick = { focusManager.clearFocus() if (paytoUri != null) { - onMakeDeposit(amount, paytoUri) + onPaytoSelected(paytoUri, currency) } }, ) { - Text(stringResource(R.string.send_deposit_create_button)) + Text(stringResource(R.string.withdraw_select_amount)) } BottomInsetsSpacer() @@ -314,6 +231,7 @@ fun MakeDepositWireTypeChooser( Text(when(wireType) { WireType.IBAN -> stringResource(R.string.send_deposit_iban) WireType.TalerBank -> stringResource(R.string.send_deposit_taler) + WireType.Bitcoin -> stringResource(R.string.send_deposit_bitcoin) else -> error("unknown method") }) } @@ -341,19 +259,14 @@ fun MakeDepositErrorComposable( @Composable fun PreviewMakeDepositComposable() { Surface { - val state = DepositState.FeesChecked( - effectiveDepositAmount = Amount.fromString("TESTKUDOS", "42.00"), - totalDepositCost = Amount.fromString("TESTKUDOS", "42.23"), - ) MakeDepositComposable( - state = state, defaultCurrency = "KUDOS", currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), - getCurrencySpec = { null }, getDepositWireTypes = { GetDepositWireTypesForCurrencyResponse( wireTypes = listOf( WireType.IBAN, WireType.TalerBank, + WireType.Bitcoin, ), wireTypeDetails = listOf( WireTypeDetails( @@ -366,13 +279,8 @@ fun PreviewMakeDepositComposable() { ), ), )}, - checkDeposit = { _, _ -> CheckDepositResult.Success( - totalDepositCost = Amount.fromJSONString("KUDOS:10"), - effectiveDepositAmount = Amount.fromJSONString("KUDOS:12"), - maxDepositAmountEffective = Amount.fromJSONString("KUDOS:12") - ) }, validateIban = { true }, - onMakeDeposit = { _, _ -> }, + onPaytoSelected = { _, _ -> }, onClose = {}, ) } diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt @@ -193,7 +193,8 @@ fun CurrencyDropdown( ) { OutlinedTextField( modifier = Modifier - .clickable(onClick = { if (!readOnly) expanded = true }), + .clickable(onClick = { if (!readOnly) expanded = true }) + .fillMaxWidth(), value = currencies.getOrNull(selectedIndex) ?: initialCurrency // wallet is empty or currency is new ?: error("no currency available"), diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -216,6 +216,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="pay_peer_title">Pay request</string> <string name="send_deposit_account">Account</string> <string name="send_deposit_account_warning">You must enter an account that you control, otherwise you will not be able to fulfill the regulatory requirements.</string> + <string name="send_deposit_bitcoin">Bitcoin</string> <string name="send_deposit_bitcoin_address">Bitcoin address</string> <string name="send_deposit_bitcoin_create_button">Transfer Bitcoin</string> <string name="send_deposit_button_label">Deposit</string>