commit 493262041900da6a499e6d9157588133c46f7e3b parent 6553a42ac1760cbb201a3877fc7a2fe10f5df60f Author: Iván Ávalos <avalos@disroot.org> Date: Thu, 31 Oct 2024 19:31:37 +0100 [wallet] Replace obsoleted ManualWithdrawalFragment with PromptWithdrawFragment Diffstat:
9 files changed, 355 insertions(+), 451 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt b/wallet/src/main/java/net/taler/wallet/HandleUriFragment.kt @@ -119,8 +119,11 @@ class HandleUriFragment: Fragment() { action.startsWith("withdraw/", ignoreCase = true) -> { Log.v(TAG, "navigating!") // there's more than one entry point, so use global action - model.withdrawManager.getWithdrawalDetails(u2) - val args = bundleOf("editableCurrency" to false) + val args = bundleOf( + "withdrawUri" to u2, + "editableCurrency" to false, + ) + model.withdrawManager.resetWithdrawal() findNavController().navigate(R.id.action_handleUri_to_promptWithdraw, args) } @@ -240,14 +243,11 @@ class HandleUriFragment: Fragment() { model.exchangeManager.withdrawalExchange = exchange withContext(Dispatchers.Main) { model.showProgressBar.value = false - val args = Bundle().apply { - putBoolean("hideScanQr", true) - if (response.amount != null) { - putString("amount", response.amount.toJSONString()) - } - } - - findNavController().navigate(R.id.action_handleUri_to_manualWithdrawal, args) + val args = bundleOf( + "exchangeBaseUrl" to response.exchangeBaseUrl, + "amount" to response.amount?.toJSONString(), + ) + findNavController().navigate(R.id.promptWithdraw, args) } } } diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt @@ -68,7 +68,7 @@ fun AmountCurrencyField( readOnly: Boolean = false, ) { var text by remember(initialAmount) { mutableStateOf(initialAmount.amountStr) } - var selectedCurrency by rememberSaveable { mutableStateOf(initialCurrency ?: currencies[0]) } + var selectedCurrency by rememberSaveable(initialCurrency) { mutableStateOf(initialCurrency ?: currencies[0]) } val selectedSpec: CurrencySpecification? = getCurrencySpec(selectedCurrency) val amount = remember(selectedCurrency, text) { getAmount(selectedCurrency, text) } diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt @@ -152,8 +152,13 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { } override fun onManualWithdraw(item: ExchangeItem) { - exchangeManager.withdrawalExchange = item - findNavController().navigate(R.id.action_nav_settings_exchanges_to_nav_exchange_manual_withdrawal) + model.withdrawManager.resetWithdrawal() + val args = bundleOf( + "editableCurrency" to false, + "exchangeBaseUrl" to item.exchangeBaseUrl, + "amount" to item.currency?.let { Amount.zero(it).toJSONString() }, + ) + findNavController().navigate(R.id.promptWithdraw, args) } override fun onPeerReceive(item: ExchangeItem) { diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -130,6 +130,10 @@ class ExchangeManager( emit(findExchange(currency)) } + fun findExchangeForBaseUrl(url: String): Flow<ExchangeItem?> = flow { + emit(findExchangeByUrl(url)) + } + @WorkerThread suspend fun findExchange(currency: String): ExchangeItem? { var exchange: ExchangeItem? = null diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -20,38 +20,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf @@ -63,17 +43,14 @@ 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.CurrencySpecification import net.taler.common.EventObserver import net.taler.wallet.MainViewModel import net.taler.wallet.R 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.BottomButtonBox import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware @@ -81,10 +58,6 @@ import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.exchanges.ExchangeTosStatus import net.taler.wallet.exchanges.SelectExchangeDialogFragment import net.taler.wallet.showError -import net.taler.wallet.transactions.AmountType -import net.taler.wallet.transactions.TransactionAmountComposable -import net.taler.wallet.transactions.TransactionInfoComposable -import net.taler.wallet.useDebounce import net.taler.wallet.withdraw.WithdrawStatus.Status.InfoReceived import net.taler.wallet.withdraw.WithdrawStatus.Status.Loading import net.taler.wallet.withdraw.WithdrawStatus.Status.ManualTransferRequired @@ -103,7 +76,6 @@ class PromptWithdrawFragment: Fragment() { private val selectExchangeDialog = SelectExchangeDialogFragment() private var editableCurrency: Boolean = true - private var startup: Boolean = true private var navigating: Boolean = false override fun onCreateView( @@ -111,11 +83,26 @@ class PromptWithdrawFragment: Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ) = ComposeView(requireContext()).apply { + val withdrawUri = arguments?.getString("withdrawUri") + val exchangeBaseUrl = arguments?.getString("exchangeBaseUrl") + val amount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } editableCurrency = arguments?.getBoolean("editableCurrency") ?: true + val currencies = balanceManager.getCurrencies() setContent { val status by withdrawManager.withdrawStatus.collectAsStateLifecycleAware() - var defaultExchange by remember { mutableStateOf<ExchangeItem?>(null) } + + val exchange by remember(status.exchangeBaseUrl) { + status.exchangeBaseUrl + ?.let { exchangeManager.findExchangeForBaseUrl(it) } + ?: MutableStateFlow(null) + }.collectAsStateLifecycleAware(null) + + val defaultCurrency = amount?.currency + ?: status.currency + ?: transactionManager.selectedScope.value?.currency + ?: currencies.firstOrNull() + ?: error("no default currency specified") TalerSurface { status.let { s -> @@ -129,22 +116,17 @@ class PromptWithdrawFragment: Fragment() { None, InfoReceived, TosReviewRequired, Updating -> { val spec = remember(s) { - defaultExchange?.scopeInfo?.let { scopeInfo -> + exchange?.scopeInfo?.let { scopeInfo -> balanceManager.getSpecForScopeInfo(scopeInfo) } ?: s.currency?.let { balanceManager.getSpecForCurrency(it) } } - val currencies = balanceManager.getCurrencies() - // TODO: use scopeInfo instead of currency! WithdrawalShowInfo( status = s, - defaultCurrency = s.currency - ?: transactionManager.selectedScope.value?.currency - ?: currencies.firstOrNull() - ?: error("no default currency specified"), + defaultCurrency = defaultCurrency, editableCurrency = editableCurrency, currencies = currencies, spec = spec, @@ -152,11 +134,7 @@ class PromptWithdrawFragment: Fragment() { selectExchange() }, onSelectAmount = { amount -> - if (s.status == None) { - getInitialDetails(amount) - } else { - getUpdatedDetails(s, amount) - } + withdrawManager.getWithdrawalDetails(amount = amount, loading = false) }, onTosReview = { // TODO: rewrite ToS review screen in compose @@ -173,10 +151,25 @@ class PromptWithdrawFragment: Fragment() { } } + LaunchedEffect(exchange) { + exchangeBaseUrl?.let { + withdrawManager.getWithdrawalDetails(exchangeBaseUrl = it) + } + } + LaunchedEffect(Unit) { - val s = status - if (s.uriInfo?.amount == null && s.uriInfo?.defaultExchangeBaseUrl != null) { - defaultExchange = exchangeManager.findExchangeByUrl(s.uriInfo.defaultExchangeBaseUrl) + if (withdrawUri != null) { + // get withdrawal details for taler:// URI + withdrawManager.getWithdrawalDetails(withdrawUri, loading = true) + } else if (exchangeBaseUrl != null) { + // get withdrawal details for exchange URL + withdrawManager.setWithdrawalExchange(exchangeBaseUrl) + } else if (amount != null) { + // get withdrawal details for amount/currency + withdrawManager.getWithdrawalDetails(amount = amount, loading = true) + } else { + // get withdrawal details for default currency + withdrawManager.getWithdrawalDetails(amount = Amount.zero(defaultCurrency), loading = true) } } } @@ -198,16 +191,6 @@ class PromptWithdrawFragment: Fragment() { } when (status.status) { - InfoReceived -> if (startup) { // only fire at startup - startup = false - withdrawManager.getWithdrawalDetails( - amount = status.amountInfo?.amountRaw ?: status.uriInfo?.amount, - exchangeBaseUrl = status.exchangeBaseUrl ?: status.uriInfo?.defaultExchangeBaseUrl, - // don't show loading screen when withdrawal is not from QR/URI - loading = !editableCurrency, - ) - } - ManualTransferRequired -> { if (!navigating) { navigating = true @@ -249,62 +232,6 @@ class PromptWithdrawFragment: Fragment() { } } - // TODO: move to manager, maybe? - private fun getInitialDetails(amount: Amount) { - viewLifecycleOwner.lifecycleScope.launch { - exchangeManager.findExchangeForCurrency(amount.currency).collect { exchange -> - if (exchange == null) { - Toast.makeText(requireContext(), "No exchange available", Toast.LENGTH_LONG).show() - return@collect - } - - exchangeManager.withdrawalExchange = exchange - withdrawManager.getWithdrawalDetails( - exchangeBaseUrl = exchange.exchangeBaseUrl, - uriInfo = WithdrawalDetailsForUri( - amount = amount, - currency = amount.currency, - editableAmount = true, - ), - amount = amount, - loading = false, - ) - } - } - } - - // TODO: move to manager, maybe? - private fun getUpdatedDetails(s: WithdrawStatus, amount: Amount) { - val oldAmount = s.amountInfo?.amountRaw ?: s.uriInfo?.amount - if (oldAmount == amount) return - - if (oldAmount?.currency == amount.currency) { - withdrawManager.getWithdrawalDetails( - amount = amount, - exchangeBaseUrl = s.exchangeBaseUrl, - loading = false, - ) - return - } - - viewLifecycleOwner.lifecycleScope.launch { - exchangeManager.findExchangeForCurrency(amount.currency).collect { exchange -> - if (exchange == null) { - Toast.makeText(requireContext(), "No exchange available", Toast.LENGTH_LONG) - .show() - return@collect - } - - exchangeManager.withdrawalExchange = exchange - withdrawManager.getWithdrawalDetails( - amount = amount, - exchangeBaseUrl = exchange.exchangeBaseUrl, - loading = false, - ) - } - } - } - private fun selectExchange() { val exchanges = withdrawManager.withdrawStatus.value.uriInfo?.possibleExchanges ?: return selectExchangeDialog.setExchanges(exchanges) @@ -319,191 +246,6 @@ class PromptWithdrawFragment: Fragment() { } @Composable -fun WithdrawalShowInfo( - status: WithdrawStatus, - defaultCurrency: String, - editableCurrency: Boolean, - currencies: List<String>, - spec: CurrencySpecification?, - onSelectAmount: (amount: Amount) -> Unit, - onSelectExchange: () -> Unit, - onTosReview: () -> Unit, - onConfirm: (age: Int?) -> Unit, -) { - val defaultAmount = status.amountInfo?.amountRaw - ?: status.uriInfo?.amount - ?: Amount.zero(defaultCurrency) - val maxAmount = status.uriInfo?.maxAmount - val editableAmount = status.uriInfo?.editableAmount ?: editableCurrency - val wireFee = status.uriInfo?.wireFee ?: Amount.zero(defaultCurrency) - val exchange = status.exchangeBaseUrl - val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList() - val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() - - var startup by remember { mutableStateOf(true) } - var selectedAmount by remember { mutableStateOf(defaultAmount) } - 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 - } - - selectedAmount.useDebounce { - if (startup) { // do not fire at startup - startup = false - } else { - onSelectAmount(it) - } - } - - Column(Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(scrollState) - .fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (editableAmount) { - AmountCurrencyField( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - initialAmount = selectedAmount.withSpec(spec), - initialCurrency = selectedAmount.currency, - currencies = currencies, - editableCurrency = editableCurrency, - onAmountChanged = { selectedAmount = it }, - getCurrencySpec = { spec }, - label = { Text(stringResource(R.string.amount_withdraw)) }, - isError = selectedAmount.isZero() || maxAmount != null && selectedAmount > maxAmount, - supportingText = { - if (insufficientBalance && maxAmount != null) { - Text(stringResource(R.string.amount_excess, maxAmount)) - } - }, - ) - } else { - TransactionAmountComposable( - label = if (wireFee.isZero()) { - stringResource(R.string.amount_total) - } else { - stringResource(R.string.amount_chosen) - }, - amount = selectedAmount, - amountType = if (wireFee.isZero()) { - AmountType.Positive - } else { - AmountType.Neutral - }, - ) - } - - if (!wireFee.isZero()) { - TransactionAmountComposable( - label = stringResource(R.string.amount_fee), - amount = wireFee, - amountType = AmountType.Negative, - ) - - selectedAmount?.let { amount -> - TransactionAmountComposable( - label = stringResource(R.string.amount_total), - amount = amount + wireFee, - amountType = AmountType.Positive, - ) - } - } - - exchange?.let { - TransactionInfoComposable( - label = stringResource(R.string.withdraw_exchange), - info = cleanExchange(it), - trailing = { - if (possibleExchanges.size > 1) { - IconButton( - modifier = Modifier.padding(start = 8.dp), - onClick = { onSelectExchange() }, - ) { - Icon( - Icons.Default.Edit, - contentDescription = stringResource(R.string.edit), - ) - } - } - }, - ) - } - - var expanded by remember { mutableStateOf(false) } - - if (ageRestrictionOptions.isNotEmpty()) { - TransactionInfoComposable( - label = stringResource(R.string.withdraw_restrict_age), - info = selectedAge?.toString() - ?: stringResource(R.string.withdraw_restrict_age_unrestricted) - ) { - IconButton( - modifier = Modifier.padding(start = 8.dp), - onClick = { expanded = true }) { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = stringResource(R.string.edit), - ) - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.withdraw_restrict_age_unrestricted)) }, - onClick = { - selectedAge = null - expanded = false - }, - ) - - ageRestrictionOptions.forEach { age -> - DropdownMenuItem( - text = { Text(age.toString()) }, - onClick = { - selectedAge = age - expanded = false - }, - ) - } - } - } - } - } - - BottomButtonBox(Modifier.fillMaxWidth()) { - Button( - enabled = !error - && status.status != Updating - && selectedAmount?.let { !it.isZero() } == true, - onClick = { - if (status.status == TosReviewRequired) { - onTosReview() - } else selectedAmount?.let { - onConfirm(selectedAge) - } - }, - ) { - when (status.status) { - Updating -> CircularProgressIndicator(modifier = Modifier.size(15.dp)) - TosReviewRequired -> Text(stringResource(R.string.withdraw_button_tos)) - else -> Text(stringResource(R.string.withdraw_button_confirm)) - } - } - } - } -} - -@Composable fun WithdrawalError( error: TalerErrorInfo, ) { diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -240,7 +240,16 @@ class WithdrawManager( _withdrawTestStatus.value = TestWithdrawStatus.None } - fun getWithdrawalDetails(uri: String) = scope.launch { + fun setWithdrawalExchange(exchangeBaseUrl: String) { + _withdrawStatus.update { value -> + value.copy(exchangeBaseUrl = exchangeBaseUrl) + } + } + + fun getWithdrawalDetails( + uri: String, + loading: Boolean = true, + ) = scope.launch { _withdrawStatus.update { WithdrawStatus( talerWithdrawUri = uri, @@ -248,6 +257,7 @@ class WithdrawManager( ) } + // first get URI details api.request("getWithdrawalDetailsForUri", WithdrawalDetailsForUri.serializer()) { put("talerWithdrawUri", uri) }.onError { error -> @@ -262,6 +272,13 @@ class WithdrawManager( exchangeBaseUrl = details.defaultExchangeBaseUrl, ) } + + // then extend with amount details + getWithdrawalDetails( + amount = details.amount, + exchangeBaseUrl = details.defaultExchangeBaseUrl, + loading = loading, + ) } } @@ -275,28 +292,34 @@ class WithdrawManager( value.copy(status = if (loading) Loading else Updating) } - val exchangeBaseUrl2 = exchangeBaseUrl - ?: status.exchangeBaseUrl - ?: error("no exchangeBaseUrl") + val a = amount + // reset amount to zero when exchangeBaseUrl changes but amount is not set + ?: exchangeBaseUrl?.let { url -> exchangeManager.findExchangeByUrl(url)?.currency?.let { Amount.zero(it) } } + ?: status.uriInfo?.amount + ?: status.amountInfo?.amountRaw + ?: error("no amount for withdrawal") - val amount2 = amount?.toJSONString() - ?: status.uriInfo?.amount?.toJSONString() - ?: status.amountInfo?.amountRaw?.toJSONString() - ?: error("no amount") + val exchange = if (exchangeBaseUrl == null && amount?.currency != status.currency) { + // find exchange from currency in absence of exchangeBaseUrl + amount?.currency?.let { exchangeManager.findExchange(it) } + } else { + exchangeBaseUrl?.let { exchangeManager.findExchangeByUrl(it) } + ?: status.exchangeBaseUrl?.let { exchangeManager.findExchangeByUrl(it) } + ?: amount?.currency?.let { exchangeManager.findExchange(it) } + } ?: error("no exchange for withdrawal") api.request("getWithdrawalDetailsForAmount", WithdrawalDetailsForAmount.serializer()) { - put("exchangeBaseUrl", exchangeBaseUrl2) - put("amount", amount2) + put("exchangeBaseUrl", exchange.exchangeBaseUrl) + put("amount", a.toJSONString()) }.onError { error -> handleError("getWithdrawalDetailsForAmount", error) }.onSuccess { details -> scope.launch { - val exchange = exchangeManager.findExchangeByUrl(exchangeBaseUrl2) - if (exchange?.tosStatus == ExchangeTosStatus.Accepted) { + if (exchange.tosStatus == ExchangeTosStatus.Accepted) { _withdrawStatus.update { value -> value.copy( status = InfoReceived, - exchangeBaseUrl = exchangeBaseUrl2, + exchangeBaseUrl = exchange.exchangeBaseUrl, uriInfo = uriInfo ?: value.uriInfo, amountInfo = details, currency = details.amountRaw.currency, @@ -308,7 +331,7 @@ class WithdrawManager( status = TosReviewRequired, amountInfo = details, currency = details.amountRaw.currency, - exchangeBaseUrl = exchangeBaseUrl, + exchangeBaseUrl = exchange.exchangeBaseUrl, ) } } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawalShowInfo.kt @@ -0,0 +1,244 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.withdraw + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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 +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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.cleanExchange +import net.taler.wallet.compose.AmountCurrencyField +import net.taler.wallet.compose.BottomButtonBox +import net.taler.wallet.systemBarsPaddingBottom +import net.taler.wallet.transactions.AmountType +import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.transactions.TransactionInfoComposable +import net.taler.wallet.useDebounce +import net.taler.wallet.withdraw.WithdrawStatus.Status.TosReviewRequired +import net.taler.wallet.withdraw.WithdrawStatus.Status.Updating + +@Composable +fun WithdrawalShowInfo( + status: WithdrawStatus, + defaultCurrency: String, + editableCurrency: Boolean, + currencies: List<String>, + spec: CurrencySpecification?, + onSelectAmount: (amount: Amount) -> Unit, + onSelectExchange: () -> Unit, + onTosReview: () -> Unit, + onConfirm: (age: Int?) -> Unit, +) { + val defaultAmount = status.amountInfo?.amountRaw + ?: status.uriInfo?.amount + ?: Amount.zero(defaultCurrency) + val maxAmount = status.uriInfo?.maxAmount + val editableAmount = status.uriInfo?.editableAmount ?: true + val wireFee = status.uriInfo?.wireFee ?: Amount.zero(defaultCurrency) + val exchange = status.exchangeBaseUrl + val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList() + val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() + + var startup by remember { mutableStateOf(true) } + var selectedAmount by remember { mutableStateOf(defaultAmount) } + 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 + } + + selectedAmount.useDebounce { + if (startup) { // do not fire at startup + startup = false + } else { + onSelectAmount(it) + } + } + + Column( + Modifier + .fillMaxSize() + .imePadding(), + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(scrollState) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (editableAmount) { + AmountCurrencyField( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + initialAmount = selectedAmount.withSpec(spec), + initialCurrency = defaultCurrency, + currencies = currencies, + editableCurrency = editableCurrency, + onAmountChanged = { selectedAmount = it }, + getCurrencySpec = { spec }, + label = { Text(stringResource(R.string.amount_withdraw)) }, + isError = selectedAmount.isZero() || maxAmount != null && selectedAmount > maxAmount, + supportingText = { + if (insufficientBalance && maxAmount != null) { + Text(stringResource(R.string.amount_excess, maxAmount)) + } + }, + ) + } else { + TransactionAmountComposable( + label = if (wireFee.isZero()) { + stringResource(R.string.amount_total) + } else { + stringResource(R.string.amount_chosen) + }, + amount = selectedAmount, + amountType = if (wireFee.isZero()) { + AmountType.Positive + } else { + AmountType.Neutral + }, + ) + } + + if (!wireFee.isZero()) { + TransactionAmountComposable( + label = stringResource(R.string.amount_fee), + amount = wireFee, + amountType = AmountType.Negative, + ) + + TransactionAmountComposable( + label = stringResource(R.string.amount_total), + amount = selectedAmount + wireFee, + amountType = AmountType.Positive, + ) + } + + exchange?.let { + TransactionInfoComposable( + label = stringResource(R.string.withdraw_exchange), + info = cleanExchange(it), + trailing = { + if (possibleExchanges.size > 1) { + IconButton( + modifier = Modifier.padding(start = 8.dp), + onClick = { onSelectExchange() }, + ) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(R.string.edit), + ) + } + } + }, + ) + } + + var expanded by remember { mutableStateOf(false) } + + if (ageRestrictionOptions.isNotEmpty()) { + TransactionInfoComposable( + label = stringResource(R.string.withdraw_restrict_age), + info = selectedAge?.toString() + ?: stringResource(R.string.withdraw_restrict_age_unrestricted) + ) { + IconButton( + modifier = Modifier.padding(start = 8.dp), + onClick = { expanded = true }) { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.edit), + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.withdraw_restrict_age_unrestricted)) }, + onClick = { + selectedAge = null + expanded = false + }, + ) + + ageRestrictionOptions.forEach { age -> + DropdownMenuItem( + text = { Text(age.toString()) }, + onClick = { + selectedAge = age + expanded = false + }, + ) + } + } + } + } + } + + BottomButtonBox(Modifier.fillMaxWidth()) { + Button( + modifier = Modifier + .systemBarsPaddingBottom(), + enabled = !error + && status.status != Updating + && !selectedAmount.isZero(), + onClick = { + if (status.status == TosReviewRequired) { + onTosReview() + } else onConfirm(selectedAge) + }, + ) { + when (status.status) { + Updating -> CircularProgressIndicator(modifier = Modifier.size(15.dp)) + TosReviewRequired -> Text(stringResource(R.string.withdraw_button_tos)) + else -> Text(stringResource(R.string.withdraw_button_confirm)) + } + } + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawFragment.kt @@ -1,101 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2022 Taler Systems S.A. - * - * GNU Taler is free software; you can redistribute it and/or modify it under the - * terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3, or (at your option) any later version. - * - * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - * A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -package net.taler.wallet.withdraw.manual - -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import net.taler.common.Amount -import net.taler.common.AmountParserException -import net.taler.common.hideKeyboard -import net.taler.wallet.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.databinding.FragmentManualWithdrawBinding -import java.util.Locale - -class ManualWithdrawFragment : Fragment() { - - private val model: MainViewModel by activityViewModels() - private val exchangeManager by lazy { model.exchangeManager } - private val exchangeItem by lazy { requireNotNull(exchangeManager.withdrawalExchange) } - private val withdrawManager by lazy { model.withdrawManager } - - private lateinit var ui: FragmentManualWithdrawBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - ui = FragmentManualWithdrawBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - arguments?.getString("amount")?.let { - val amount = Amount.fromJSONString(it) - ui.amountView.setText(amount.amountStr) - } - - arguments?.getBoolean("hideScanQr")?.let { - if (it) { - ui.qrCodeButton.visibility = GONE - ui.orView.visibility = GONE - } - } - - ui.qrCodeButton.setOnClickListener { - model.scanCode() - } - ui.currencyView.text = exchangeItem.currency - val paymentOptions = exchangeItem.paytoUris.mapNotNull { paytoUri -> - Uri.parse(paytoUri).authority?.uppercase(Locale.getDefault()) - }.joinToString(separator = "\n", prefix = "• ") - ui.paymentOptionsLabel.text = - getString(R.string.withdraw_manual_payment_options, exchangeItem.name, paymentOptions) - ui.checkFeesButton.setOnClickListener { onCheckFees() } - } - - private fun onCheckFees() { - val currency = exchangeItem.currency - if (currency == null || ui.amountView.text?.isEmpty() != false) { - ui.amountLayout.error = getString(R.string.withdraw_amount_error) - return - } - ui.amountLayout.error = null - val amount: Amount - try { - amount = Amount.fromString(currency, ui.amountView.text.toString()) - } catch (e: AmountParserException) { - ui.amountLayout.error = getString(R.string.withdraw_amount_error) - return - } - ui.amountView.hideKeyboard() - - withdrawManager.getWithdrawalDetails( - exchangeBaseUrl = exchangeItem.exchangeBaseUrl, - amount = amount, - ) - findNavController().navigate(R.id.action_nav_exchange_manual_withdrawal_to_promptWithdraw) - } - -} diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml @@ -43,11 +43,6 @@ app:nullable="false" /> <action - android:id="@+id/action_handleUri_to_manualWithdrawal" - app:destination="@id/nav_exchange_manual_withdrawal" - app:popUpTo="@id/nav_main" /> - - <action android:id="@+id/action_handleUri_to_promptPayment" app:destination="@id/promptPayment" app:popUpTo="@id/nav_main" /> @@ -103,28 +98,7 @@ <fragment android:id="@+id/nav_settings_exchanges" android:name="net.taler.wallet.exchanges.ExchangeListFragment" - android:label="@string/exchange_list_title"> - <action - android:id="@+id/action_nav_settings_exchanges_to_nav_exchange_manual_withdrawal" - app:destination="@id/nav_exchange_manual_withdrawal" /> - </fragment> - - <fragment - android:id="@+id/nav_exchange_manual_withdrawal" - android:name="net.taler.wallet.withdraw.manual.ManualWithdrawFragment" - android:label="@string/withdraw_title"> - <action - android:id="@+id/action_nav_exchange_manual_withdrawal_to_promptWithdraw" - app:destination="@id/promptWithdraw" /> - <argument - android:name="amount" - app:argType="string" - app:nullable="false" /> - <argument - android:name="hideScanQr" - app:argType="boolean" - app:nullable="false" /> - </fragment> + android:label="@string/exchange_list_title" /> <fragment android:id="@+id/nav_exchange_manual_withdrawal_success" @@ -280,9 +254,21 @@ android:name="net.taler.wallet.withdraw.PromptWithdrawFragment" android:label="@string/nav_prompt_withdraw"> <argument + android:name="withdrawUri" + app:nullable="true" + app:argType="string" /> + <argument android:name="editableCurrency" android:defaultValue="true" app:argType="boolean" /> + <argument + android:name="exchangeBaseUrl" + app:nullable="true" + app:argType="string" /> + <argument + android:name="amount" + app:nullable="true" + app:argType="string" /> <action android:id="@+id/action_promptWithdraw_to_nav_main" app:destination="@id/nav_main"