commit 2e99e3afa708639cf9e11125e39ca62e81a382ba parent 0b79b6f80a25c310bae39ffc24e562d8a285ce24 Author: Iván Ávalos <avalos@disroot.org> Date: Fri, 26 Jul 2024 13:51:08 -0600 [wallet] WIP: rewrite withdraw logic and views Diffstat:
17 files changed, 900 insertions(+), 1225 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -188,7 +188,7 @@ class MainViewModel( @UiThread fun dangerouslyReset() { - withdrawManager.testWithdrawalStatus.value = null + withdrawManager.resetTestWithdrawal() balanceManager.resetBalances() } diff --git a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt @@ -64,6 +64,7 @@ import net.taler.wallet.compose.AmountInputField import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS import net.taler.wallet.compose.TalerSurface import net.taler.wallet.exchanges.ExchangeItem +import net.taler.wallet.withdraw.WithdrawalDetailsForUri class ReceiveFundsFragment : Fragment() { private val model: MainViewModel by activityViewModels() @@ -113,9 +114,13 @@ class ReceiveFundsFragment : Fragment() { // now that we have the exchange, we can navigate exchangeManager.withdrawalExchange = exchange + withdrawManager.resetWithdrawal() withdrawManager.getWithdrawalDetails( exchangeBaseUrl = exchange.exchangeBaseUrl, - currency = amount.currency, + uriInfo = WithdrawalDetailsForUri( + amount = amount, + currency = amount.currency, + ), amount = amount, ) findNavController().navigate(R.id.action_receiveFunds_to_nav_prompt_withdraw) diff --git a/wallet/src/main/java/net/taler/wallet/compose/BottomButtonBox.kt b/wallet/src/main/java/net/taler/wallet/compose/BottomButtonBox.kt @@ -0,0 +1,86 @@ +/* + * 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.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun BottomButtonBox( + modifier: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, +) { + Row( + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leading?.let { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { it() } + } + + trailing?.let { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterEnd, + ) { it() } + } + } +} + +@Preview +@Composable +fun BottomButtonBoxPreview() { + TalerSurface { + Column { + Spacer(Modifier.height(100.dp)) + + BottomButtonBox( + modifier = Modifier.fillMaxWidth(), + leading = { + Button(onClick = {}) { + Text("Back") + } + }, + trailing = { + Button(onClick = {}) { + Text("Continue") + } + } + ) + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsFragment.kt @@ -21,6 +21,9 @@ import android.view.View import androidx.activity.result.contract.ActivityResultContracts.OpenDocument import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat @@ -29,6 +32,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch import net.taler.common.showError import net.taler.wallet.BuildConfig.FLAVOR import net.taler.wallet.BuildConfig.VERSION_CODE @@ -36,7 +40,7 @@ import net.taler.wallet.BuildConfig.VERSION_NAME import net.taler.wallet.MainViewModel import net.taler.wallet.R import net.taler.wallet.showError -import net.taler.wallet.withdraw.WithdrawTestStatus +import net.taler.wallet.withdraw.TestWithdrawStatus import java.lang.System.currentTimeMillis @@ -117,16 +121,21 @@ class SettingsFragment : PreferenceFragmentCompat() { true } - withdrawManager.testWithdrawalStatus.observe(viewLifecycleOwner) { status -> - if (status == null) return@observe - val loading = status is WithdrawTestStatus.Withdrawing - prefWithdrawTest.isEnabled = !loading - model.showProgressBar.value = loading - if (status is WithdrawTestStatus.Error) { - requireActivity().showError(R.string.withdraw_error_test, status.message) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + withdrawManager.withdrawTestStatus.collect { status -> + if (status is TestWithdrawStatus.None) return@collect + val loading = status is TestWithdrawStatus.Withdrawing + prefWithdrawTest.isEnabled = !loading + model.showProgressBar.value = loading + if (status is TestWithdrawStatus.Error) { + requireActivity().showError(R.string.withdraw_error_test, status.message) + } + withdrawManager.resetTestWithdrawal() + } } - withdrawManager.testWithdrawalStatus.value = null } + prefWithdrawTest.setOnPreferenceClickListener { withdrawManager.withdrawTestkudos() Snackbar.make(requireView(), getString(R.string.settings_test_withdrawal), LENGTH_LONG).show() diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -29,6 +30,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -127,15 +129,26 @@ fun TransactionAmountComposable(label: String, amount: Amount, amountType: Amoun } @Composable -fun TransactionInfoComposable(label: String, info: String) { +fun TransactionInfoComposable( + label: String, + info: String, + trailing: (@Composable () -> Unit)? = null, +) { Text( modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), text = label, style = MaterialTheme.typography.bodyMedium, ) - Text( + + Row( modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), - text = info, - fontSize = 24.sp, - ) + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = info, + fontSize = 24.sp, + ) + + trailing?.let { it() } + } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt @@ -26,12 +26,12 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.TalerSurface import net.taler.wallet.launchInAppBrowser import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi import net.taler.wallet.withdraw.TransactionWithdrawalComposable -import net.taler.wallet.withdraw.createManualTransferRequired class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListener { @@ -79,14 +79,19 @@ class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListene if (tx !is TransactionWithdrawal) return if (tx.withdrawalDetails !is ManualTransfer) return if (tx.withdrawalDetails.exchangeCreditAccountDetails.isNullOrEmpty()) return - val status = createManualTransferRequired( + + withdrawManager.viewManualWithdrawal( transactionId = tx.transactionId, exchangeBaseUrl = tx.exchangeBaseUrl, amountRaw = tx.amountRaw, amountEffective = tx.amountEffective, withdrawalAccountList = tx.withdrawalDetails.exchangeCreditAccountDetails, + scopeInfo = transactionManager.selectedScope ?: ScopeInfo.Exchange( + currency = tx.amountRaw.currency, + url = tx.exchangeBaseUrl, + ), ) - withdrawManager.viewManualWithdrawal(status) + findNavController().navigate( R.id.action_nav_transactions_detail_withdrawal_to_nav_exchange_manual_withdrawal_success, ) diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/ErrorFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/ErrorFragment.kt @@ -57,9 +57,9 @@ class ErrorFragment : Fragment() { // show dev error message if dev mode is on val status = withdrawManager.withdrawStatus.value - if (model.devMode.value == true && status is WithdrawStatus.Error) { + if (model.devMode.value == true && status.error != null) { ui.errorDevMessage.visibility = VISIBLE - ui.errorDevMessage.text = status.message + ui.errorDevMessage.text = status.error.userFacingMsg } else { ui.errorDevMessage.visibility = GONE } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -1,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2020 Taler Systems S.A. + * (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 @@ -20,55 +20,187 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.rememberCoroutineScope +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.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +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.launch import net.taler.common.Amount +import net.taler.common.CurrencySpecification import net.taler.common.EventObserver -import net.taler.common.fadeIn -import net.taler.common.fadeOut 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.databinding.FragmentPromptWithdrawBinding +import net.taler.wallet.compose.AmountInputField +import net.taler.wallet.compose.BottomButtonBox +import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.exchanges.SelectExchangeDialogFragment -import net.taler.wallet.withdraw.WithdrawStatus.Loading -import net.taler.wallet.withdraw.WithdrawStatus.ReceivedDetails -import net.taler.wallet.withdraw.WithdrawStatus.TosReviewRequired -import net.taler.wallet.withdraw.WithdrawStatus.Withdrawing -import net.taler.wallet.withdraw.WithdrawStatus.NeedsAmount -import net.taler.wallet.withdraw.WithdrawStatus.NeedsExchange - -class PromptWithdrawFragment : Fragment() { - +import net.taler.wallet.getAmount +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.* + +class PromptWithdrawFragment: Fragment() { private val model: MainViewModel by activityViewModels() private val withdrawManager by lazy { model.withdrawManager } private val transactionManager by lazy { model.transactionManager } + private val exchangeManager by lazy { model.exchangeManager } + private val balanceManager by lazy { model.balanceManager } private val selectExchangeDialog = SelectExchangeDialogFragment() - private lateinit var ui: FragmentPromptWithdrawBinding - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - ui = FragmentPromptWithdrawBinding.inflate(inflater, container, false) - return ui.root + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setContent { + val status by withdrawManager.withdrawStatus.collectAsStateLifecycleAware() + val coroutineScope = rememberCoroutineScope() + var defaultExchange by remember { mutableStateOf<ExchangeItem?>(null) } + + TalerSurface { + status.let { s -> + if (s.error != null) { + WithdrawalError(error = s.error) + return@let + } + + when (s.status) { + None, Loading, TosReviewRequired -> LoadingScreen() + + InfoReceived, Updating -> { + val spec = remember(s) { + defaultExchange?.scopeInfo?.let { scopeInfo -> + balanceManager.getSpecForScopeInfo(scopeInfo) + } ?: balanceManager.getSpecForCurrency(s.currency!!) + } + + WithdrawalShowInfo( + status = s, + currency = s.currency!!, + spec = spec, + onSelectExchange = { + selectExchange() + }, + onSelectAmount = { amount -> + withdrawManager.getWithdrawalDetails( + amount = amount, + exchangeBaseUrl = s.exchangeBaseUrl!!, + loading = false, + ) + }, + onConfirm = { age -> + withdrawManager.acceptWithdrawal(age) + } + ) + } + else -> {} + } + } + } + + LaunchedEffect(Unit) { + coroutineScope.launch { + val s = status + if (s.uriInfo?.amount == null && s.uriInfo?.defaultExchangeBaseUrl != null) { + defaultExchange = exchangeManager.findExchangeByUrl(s.uriInfo.defaultExchangeBaseUrl) + } + } + } + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - withdrawManager.withdrawStatus.observe(viewLifecycleOwner) { - showWithdrawStatus(it) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + withdrawManager.withdrawStatus.collect { status -> + if (status.error != null) { + showError(status.error) + } + + if (status.exchangeBaseUrl == null + && selectExchangeDialog.dialog?.isShowing != true) { + selectExchange() + } + + when (status.status) { + // TODO: rewrite ToS review screen in compose + TosReviewRequired -> { + findNavController().navigate( + R.id.action_promptWithdraw_to_reviewExchangeTOS, + ) + } + + ManualTransferRequired -> { + findNavController().navigate( + R.id.action_promptWithdraw_to_nav_exchange_manual_withdrawal_success, + ) + } + + Success -> lifecycleScope.launch { + Snackbar.make(requireView(), R.string.withdraw_initiated, LENGTH_LONG).show() + if (transactionManager.selectTransaction(status.transactionId!!)) { + findNavController().navigate(R.id.action_promptWithdraw_to_nav_transactions_detail_withdrawal) + } else { + findNavController().navigate(R.id.action_promptWithdraw_to_nav_main) + } + } + + else -> {} + } + } + } } selectExchangeDialog.exchangeSelection.observe(viewLifecycleOwner, EventObserver { @@ -76,230 +208,306 @@ class PromptWithdrawFragment : Fragment() { }) } - private fun showWithdrawStatus(status: WithdrawStatus?): Any = when (status) { - null -> model.showProgressBar.value = false - is Loading -> model.showProgressBar.value = true - is NeedsAmount -> { - model.showProgressBar.value = false - findNavController().navigate(R.id.action_promptWithdraw_to_withdrawAmount) - } - is NeedsExchange -> { - model.showProgressBar.value = false - if (selectExchangeDialog.dialog?.isShowing != true) { - selectExchange() - } else {} - } - is TosReviewRequired -> onTosReviewRequired(status) - is ReceivedDetails -> onReceivedDetails(status) - is Withdrawing -> model.showProgressBar.value = true - is WithdrawStatus.ManualTransferRequired -> { - model.showProgressBar.value = false - findNavController().navigate(R.id.action_promptWithdraw_to_nav_exchange_manual_withdrawal_success) - } - is WithdrawStatus.Success -> { - model.showProgressBar.value = false - withdrawManager.withdrawStatus.value = null - lifecycleScope.launch { - // now select new transaction based on currency and ID - if (transactionManager.selectTransaction(status.transactionId)) { - findNavController().navigate(R.id.action_promptWithdraw_to_nav_transactions_detail_withdrawal) - } else { - findNavController().navigate(R.id.action_promptWithdraw_to_nav_main) - } - Snackbar.make(requireView(), R.string.withdraw_initiated, LENGTH_LONG).show() - } - } - is WithdrawStatus.Error -> { - model.showProgressBar.value = false - findNavController().navigate(R.id.action_promptWithdraw_to_errorFragment) - } + private fun selectExchange() { + val exchanges = withdrawManager.withdrawStatus.value.uriInfo?.possibleExchanges ?: return + selectExchangeDialog.setExchanges(exchanges) + selectExchangeDialog.show(parentFragmentManager, "SELECT_EXCHANGE") } - private fun onTosReviewRequired(s: TosReviewRequired) { - model.showProgressBar.value = false - if (s.showImmediately.getIfNotConsumed() == true) { - findNavController().navigate(R.id.action_promptWithdraw_to_reviewExchangeTOS) - } else { - showContent( - amountRaw = s.amountRaw, - amountEffective = s.amountEffective, - exchange = s.exchangeBaseUrl, - uri = s.talerWithdrawUri, - exchanges = s.possibleExchanges, - editableAmount = s.editableAmount, - ) - ui.confirmWithdrawButton.apply { - text = getString(R.string.withdraw_button_tos) - setOnClickListener { - findNavController().navigate(R.id.action_promptWithdraw_to_reviewExchangeTOS) - } - isEnabled = true - } - } + private fun onExchangeSelected(exchange: ExchangeItem) { + withdrawManager.getWithdrawalDetails( + exchangeBaseUrl = exchange.exchangeBaseUrl, + ) } +} - private fun onReceivedDetails(s: ReceivedDetails) { - showContent( - amountRaw = s.amountRaw, - amountEffective = s.amountEffective, - exchange = s.exchangeBaseUrl, - uri = s.talerWithdrawUri, - ageRestrictionOptions = s.ageRestrictionOptions, - exchanges = s.possibleExchanges, - editableAmount = s.editableAmount, - ) - ui.confirmWithdrawButton.apply { - text = getString(R.string.withdraw_button_confirm) - setOnClickListener { - it.fadeOut() - ui.confirmProgressBar.fadeIn() - val ageRestrict = (ui.ageSelector.selectedItem as String?)?.let { age -> - if (age == context.getString(R.string.withdraw_restrict_age_unrestricted)) null - else age.toIntOrNull() - } - withdrawManager.acceptWithdrawal(ageRestrict) +@Composable +fun WithdrawalShowInfo( + status: WithdrawStatus, + currency: String, + spec: CurrencySpecification?, + onSelectAmount: (amount: Amount) -> Unit, + onSelectExchange: () -> Unit, + onConfirm: (age: Int?) -> Unit, +) { + val defaultAmount = status.uriInfo?.amount + val maxAmount = status.uriInfo?.maxAmount + val editableAmount = status.uriInfo?.editableAmount ?: false + val wireFee = status.uriInfo?.wireFee ?: Amount.zero(currency) + val exchange = status.exchangeBaseUrl + val possibleExchanges = status.uriInfo?.possibleExchanges ?: emptyList() + val ageRestrictionOptions = status.amountInfo?.ageRestrictionOptions ?: emptyList() + + var selectedAmount by remember { mutableStateOf(defaultAmount) } + var selectedAge by remember { mutableStateOf<Int?>(null) } + var error by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + + selectedAmount.useDebounce { + it?.let { amount -> + if (editableAmount) { + onSelectAmount(amount) } - isEnabled = true } } - private fun showContent( - amountRaw: Amount, - amountEffective: Amount, - exchange: String, - uri: String?, - exchanges: List<ExchangeItem> = emptyList(), - ageRestrictionOptions: List<Int>? = null, - editableAmount: Boolean, - ) { - model.showProgressBar.value = false - ui.progressBar.fadeOut() - - ui.introView.fadeIn() - ui.effectiveAmountView.text = amountEffective.toString() - ui.effectiveAmountView.fadeIn() - - ui.chosenAmountLabel.fadeIn() - ui.chosenAmountView.text = amountRaw.toString() - ui.chosenAmountView.fadeIn() - - if (editableAmount) { - ui.selectAmountButton.fadeIn() - ui.selectAmountButton.setOnClickListener { - findNavController().navigate(R.id.action_promptWithdraw_to_withdrawAmount) + Column(Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(scrollState) + .fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (editableAmount) { + WithdrawAmountComposable( + defaultAmount = defaultAmount?.withSpec(spec), + maxAmount = maxAmount?.withSpec(spec), + currency = currency, + spec = spec, + onAmountChanged = { amount, err -> + selectedAmount = amount + error = err + } + ) + } else { + selectedAmount?.let { amount -> + TransactionAmountComposable( + label = if (wireFee.isZero()) { + stringResource(R.string.amount_total) + } else { + stringResource(R.string.amount_chosen) + }, + amount = amount, + amountType = if (wireFee.isZero()) { + AmountType.Positive + } else { + AmountType.Neutral + }, + ) + } } - } else { - ui.selectAmountButton.fadeOut() - } - if (amountRaw > amountEffective) { - val fee = amountRaw - amountEffective - ui.feeLabel.fadeIn() - ui.feeView.text = getString(R.string.amount_negative, fee.toString()) - ui.feeView.fadeIn() - } + 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, + ) + } + } - ui.exchangeIntroView.fadeIn() - ui.withdrawExchangeUrl.text = cleanExchange(exchange) - ui.withdrawExchangeUrl.fadeIn() + 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), + ) + } + } + }, + ) + } - // no Uri for manual withdrawals, no selection for single exchange - if (uri != null && exchanges.size > 1) { - ui.selectExchangeButton.fadeIn() - ui.selectExchangeButton.setOnClickListener { - selectExchange() + 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 + }, + ) + } + } + } } } - if (ageRestrictionOptions != null) { - ui.ageLabel.fadeIn() - val context = requireContext() - val items = listOf(context.getString(R.string.withdraw_restrict_age_unrestricted)) + - ageRestrictionOptions.map { it.toString() } - ui.ageSelector.adapter = ArrayAdapter(context, R.layout.list_item_age, items) - ui.ageSelector.fadeIn() + BottomButtonBox(Modifier.fillMaxWidth()) { + Button( + enabled = !error + && status.status != Updating + && selectedAmount?.let { !it.isZero() } == true, + onClick = { + selectedAmount?.let { onConfirm(selectedAge) } + }, + ) { + if (status.status == Updating) { + CircularProgressIndicator( + modifier = Modifier.size(15.dp) + ) + } else { + Text(stringResource(R.string.withdraw_button_confirm)) + } + } } - - ui.withdrawCard.fadeIn() } +} - private fun selectExchange() { - val exchanges = when (val status = withdrawManager.withdrawStatus.value) { - is NeedsAmount -> status.possibleExchanges - is ReceivedDetails -> status.possibleExchanges - is NeedsExchange -> status.possibleExchanges - is TosReviewRequired -> status.possibleExchanges - else -> return - } - selectExchangeDialog.setExchanges(exchanges) - selectExchangeDialog.show(parentFragmentManager, "SELECT_EXCHANGE") +@Composable +fun WithdrawalError( + error: TalerErrorInfo, +) { + Box( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + contentAlignment = Center, + ) { + Text( + text = error.userFacingMsg, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.error, + ) } +} - private fun onExchangeSelected(exchange: ExchangeItem) { - val status = withdrawManager.withdrawStatus.value - - val maxAmount = when (status) { - is NeedsAmount -> status.maxAmount - is ReceivedDetails -> status.maxAmount - is NeedsExchange -> status.maxAmount - is TosReviewRequired -> status.maxAmount - else -> return - } - - val wireFee = when (status) { - is NeedsAmount -> status.wireFee - is ReceivedDetails -> status.wireFee - is NeedsExchange -> status.wireFee - is TosReviewRequired -> status.wireFee - else -> return - } - - val editableAmount = when (status) { - is NeedsAmount -> status.editableAmount - is ReceivedDetails -> status.editableAmount - is NeedsExchange -> status.editableAmount - is TosReviewRequired -> status.editableAmount - else -> return - } - - val amount = when (status) { - is ReceivedDetails -> status.amountRaw - is NeedsExchange -> status.amount - is TosReviewRequired -> status.amountRaw - else -> return - } - - val currency = when (status) { - is ReceivedDetails -> status.currency - is NeedsExchange -> status.currency - is TosReviewRequired -> status.currency - else -> return - } - - val uri = when (status) { - is ReceivedDetails -> status.talerWithdrawUri - is NeedsExchange -> status.talerWithdrawUri - is TosReviewRequired -> status.talerWithdrawUri - else -> return - } +@Composable +fun WithdrawAmountComposable( + defaultAmount: Amount?, + maxAmount: Amount?, + currency: String, + spec: CurrencySpecification?, + onAmountChanged: (amount: Amount?, error: Boolean) -> Unit, +) { + var text by remember { mutableStateOf(defaultAmount?.amountStr ?: "0") } + val amount = remember(currency, text) { getAmount(currency, text) } + val insufficientBalance = remember(amount, maxAmount) { + amount?.let { maxAmount == null || it > maxAmount } == true + } - val exchanges = when (status) { - is ReceivedDetails -> status.possibleExchanges - is NeedsExchange -> status.possibleExchanges - is TosReviewRequired -> status.possibleExchanges - else -> return - } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + AmountInputField( + modifier = Modifier + .weight(1f) + .padding(16.dp), + value = text, + onValueChange = { value -> + text = value + + // Update selected amount + getAmount(currency, text)?.let { + onAmountChanged(it, maxAmount == null || it > maxAmount) + } ?: onAmountChanged(null, true) + }, + label = { Text(stringResource(R.string.amount_withdraw)) }, + supportingText = { + if (insufficientBalance && maxAmount != null) { + Text(stringResource(R.string.amount_excess, maxAmount)) + } + }, + isError = insufficientBalance, + numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, + ) - withdrawManager.getWithdrawalDetails( - exchangeBaseUrl = exchange.exchangeBaseUrl, - currency = currency, - amount = amount, - maxAmount = maxAmount, - wireFee = wireFee, - editableAmount = editableAmount, - showTosImmediately = false, - uri = uri, - possibleExchanges = exchanges, + Text( + modifier = Modifier, + text = spec?.symbol ?: currency, + softWrap = false, + style = MaterialTheme.typography.titleLarge, ) } } + +@Preview +@Composable +fun WithdrawalShowInfoPreview() { + TalerSurface { + WithdrawalShowInfo( + WithdrawStatus( + status = Updating, + talerWithdrawUri = "taler://", + currency = "KUDOS", + exchangeBaseUrl = "exchange.head.taler.net", + transactionId = "tx:343434", + error = null, + uriInfo = WithdrawalDetailsForUri( + amount = null, + currency = "KUDOS", + editableAmount = true, + maxAmount = Amount.fromJSONString("KUDOS:10"), + wireFee = Amount.fromJSONString("KUDOS:0.2"), + defaultExchangeBaseUrl = "exchange.head.taler.net", + possibleExchanges = listOf( + ExchangeItem( + exchangeBaseUrl = "exchange.demo.taler.net", + currency = "KUDOS", + paytoUris = emptyList(), + scopeInfo = null, + ), + ExchangeItem( + exchangeBaseUrl = "exchange.head.taler.net", + currency = "KUDOS", + paytoUris = emptyList(), + scopeInfo = null, + ), + ), + ), + amountInfo = WithdrawalDetailsForAmount( + tosAccepted = true, + amountRaw = Amount.fromJSONString("KUDOS:10.1"), + amountEffective = Amount.fromJSONString("KUDOS:10.2"), + withdrawalAccountsList = emptyList(), + ageRestrictionOptions = listOf(18, 23), + scopeInfo = ScopeInfo.Exchange( + currency = "KUDOS", + url = "exchange.head.taler.net", + ), + ) + ), + currency = "KUDOS", + spec = null, + onSelectExchange = {}, + onSelectAmount = {}, + onConfirm = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/ReviewExchangeTosFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/ReviewExchangeTosFragment.kt @@ -22,14 +22,19 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import io.noties.markwon.Markwon +import kotlinx.coroutines.launch import net.taler.common.fadeIn import net.taler.common.fadeOut import net.taler.wallet.MainViewModel import net.taler.wallet.R import net.taler.wallet.databinding.FragmentReviewExchangeTosBinding import java.text.ParseException +import net.taler.wallet.withdraw.WithdrawStatus.Status.* class ReviewExchangeTosFragment : Fragment() { @@ -48,38 +53,46 @@ class ReviewExchangeTosFragment : Fragment() { ui = FragmentReviewExchangeTosBinding.inflate(inflater, container, false) return ui.root } - + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ui.acceptTosCheckBox.isChecked = false ui.acceptTosCheckBox.setOnCheckedChangeListener { _, _ -> - withdrawManager.acceptCurrentTermsOfService() + withdrawManager.acceptCurrentTos() } - withdrawManager.withdrawStatus.observe(viewLifecycleOwner) { - when (it) { - is WithdrawStatus.TosReviewRequired -> { - val sections = try { - // TODO remove next line once exchange delivers proper markdown - val text = it.tosText.replace("****************", "================") - parseTos(markwon, text) - } catch (e: ParseException) { - onTosError(e.message ?: "Unknown Error") - return@observe - } - adapter.setSections(sections) - ui.tosList.adapter = adapter - ui.tosList.fadeIn() - ui.acceptTosCheckBox.fadeIn() - ui.progressBar.fadeOut() - } - is WithdrawStatus.Loading -> { - findNavController().navigate(R.id.action_reviewExchangeTOS_to_promptWithdraw) - } - is WithdrawStatus.ReceivedDetails -> { - findNavController().navigate(R.id.action_reviewExchangeTOS_to_promptWithdraw) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + withdrawManager.withdrawStatus.collect { status -> + when (status.status) { + TosReviewRequired -> { + val tos = status.tosDetails!!.content + val sections = try { + parseTos(markwon, tos) + } catch (e: ParseException) { + onTosError(e.message ?: "Unknown Error") + return@collect + } + + adapter.setSections(sections) + ui.tosList.adapter = adapter + ui.tosList.fadeIn() + + ui.acceptTosCheckBox.fadeIn() + ui.progressBar.fadeOut() + } + + Loading -> { + findNavController().navigate(R.id.action_reviewExchangeTOS_to_promptWithdraw) + } + + InfoReceived -> { + findNavController().navigate(R.id.action_reviewExchangeTOS_to_promptWithdraw) + } + + else -> {} + } } - else -> {} } } } diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/TosSection.kt b/wallet/src/main/java/net/taler/wallet/withdraw/TosSection.kt @@ -16,7 +16,6 @@ package net.taler.wallet.withdraw -import android.util.Log import io.noties.markwon.Markwon import kotlinx.serialization.Serializable import org.commonmark.node.Code @@ -41,7 +40,8 @@ internal fun parseTos(markwon: Markwon, text: String): List<TosSection> { val sections = ArrayList<TosSection>() while (node != null) { val next: Node? = node.next - if (node is Heading && node.level == 1) { + // TODO: better sectioning logic! level 1+2 is a hack + if (node is Heading && (node.level == 1 || node.level == 2)) { // if lastHeading exists, close previous section if (lastHeading != null) { sections.add(TosSection(lastHeading, section)) diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawAmountFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawAmountFragment.kt @@ -1,276 +0,0 @@ -/* - * 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 android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -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.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -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.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.launch -import net.taler.common.Amount -import net.taler.common.AmountParserException -import net.taler.common.CurrencySpecification -import net.taler.wallet.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.compose.AmountInputField -import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS -import net.taler.wallet.compose.LoadingScreen -import net.taler.wallet.compose.TalerSurface -import net.taler.wallet.exchanges.ExchangeItem -import net.taler.wallet.getAmount -import net.taler.wallet.transactions.AmountType -import net.taler.wallet.transactions.TransactionAmountComposable -import net.taler.wallet.withdraw.WithdrawStatus.Loading -import net.taler.wallet.withdraw.WithdrawStatus.NeedsAmount -import net.taler.wallet.withdraw.WithdrawStatus.NeedsExchange -import net.taler.wallet.withdraw.WithdrawStatus.ReceivedDetails -import net.taler.wallet.withdraw.WithdrawStatus.TosReviewRequired - -class WithdrawAmountFragment: Fragment() { - private val model: MainViewModel by activityViewModels() - private val withdrawManager by lazy { model.withdrawManager } - private val balanceManager by lazy { model.balanceManager } - private val exchangeManager by lazy { model.exchangeManager } - - var selected: Boolean = false - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setContent { - val status by withdrawManager.withdrawStatus.observeAsState() - val coroutineScope = rememberCoroutineScope() - var defaultExchange by remember { mutableStateOf<ExchangeItem?>(null) } - - TalerSurface { - when (val s = status) { - is Loading -> { - LoadingScreen() - } - - else -> { - val currency = when(s) { - is NeedsAmount -> s.currency - is NeedsExchange -> s.currency - is ReceivedDetails -> s.currency - is TosReviewRequired -> s.currency - else -> error("invalid state") - } - - // Find currencySpec for currency or exchange - val exchange = defaultExchange - val spec = if (exchange?.scopeInfo != null) { - balanceManager.getSpecForScopeInfo(exchange.scopeInfo) - } else { - balanceManager.getSpecForCurrency(currency) - } - - WithdrawAmountComposable( - status = s, - spec = spec, - onSubmit = { amount -> - withdrawManager.selectWithdrawalAmount(amount) - selected = true - } - ) - } - } - } - - LaunchedEffect(Unit) { - coroutineScope.launch { - val s = status - if (s is NeedsAmount && s.defaultExchangeBaseUrl != null) { - defaultExchange = exchangeManager.findExchangeByUrl(s.defaultExchangeBaseUrl) - } - } - } - } - } - - override fun onStart() { - super.onStart() - withdrawManager.withdrawStatus.observe(viewLifecycleOwner) { status -> - when (status) { - is Loading -> {} - is NeedsAmount -> {} - else -> { - if (selected) { - findNavController().navigate(R.id.action_withdrawAmount_to_promptWithdraw) - } - } - } - } - } -} - -@Composable -fun WithdrawAmountComposable( - status: WithdrawStatus, - spec: CurrencySpecification?, - onSubmit: (amount: Amount) -> Unit, -) { - val defaultAmount = when (status) { - is NeedsAmount -> null - is NeedsExchange -> status.amount - is ReceivedDetails -> status.amountRaw - is TosReviewRequired -> status.amountRaw - else -> error("invalid state") - } - - val maxAmount = when (status) { - is NeedsAmount -> status.maxAmount - is NeedsExchange -> status.maxAmount - is ReceivedDetails -> status.maxAmount - is TosReviewRequired -> status.maxAmount - else -> error("invalid state") - } - - val currency = when (status) { - is NeedsAmount -> status.currency - is NeedsExchange -> status.currency - is ReceivedDetails -> status.currency - is TosReviewRequired -> status.currency - else -> error("invalid state") - } - - val wireFee = when (status) { - is NeedsAmount -> status.wireFee - is NeedsExchange -> status.wireFee - is ReceivedDetails -> status.wireFee - is TosReviewRequired -> status.wireFee - else -> error("invalid state") - } - - var text by remember { mutableStateOf(defaultAmount?.amountStr ?: "0") } - val amount = remember(currency, text) { getAmount(currency, text) } - val insufficientBalance = remember(amount) { - amount?.let { maxAmount == null || it > maxAmount } == true - } - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 16.dp), - ) { - // TODO: enable auto focus when AmountInputField handles it correctly - AmountInputField( - modifier = Modifier - .weight(1f) - .padding(top = 16.dp, start = 16.dp, end = 16.dp), - value = text, - onValueChange = { - text = it - }, - label = { Text(stringResource(R.string.amount_withdraw)) }, - supportingText = { - if (insufficientBalance) { - Text(stringResource(R.string.amount_excess)) - } - }, - isError = insufficientBalance, - numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, - ) - - Text( - modifier = Modifier, - text = spec?.symbol ?: currency, - softWrap = false, - style = MaterialTheme.typography.titleLarge, - ) - } - - if (wireFee != null && !wireFee.isZero()) { - TransactionAmountComposable( - label = stringResource(R.string.amount_fee), - amount = wireFee, - amountType = AmountType.Negative, - ) - - val selected = try { - Amount.fromString( - currency = currency, - str = text, - ) - } catch (_: AmountParserException) { null } - - if (selected != null) { - TransactionAmountComposable( - label = stringResource(R.string.amount_total), - amount = selected + wireFee, - amountType = AmountType.Positive, - ) - } - } - - Button( - modifier = Modifier.padding(top = 16.dp), - enabled = !insufficientBalance && amount?.isZero() == false, - onClick = { amount?.let { onSubmit(it) } }, - ) { - Text(stringResource(R.string.withdraw_select_amount)) - } - } -} - -@Preview -@Composable -fun WithdrawAmountComposablePreview() { - TalerSurface { - WithdrawAmountComposable( - status = NeedsAmount( - talerWithdrawUri = "taler://withdraw/XYZ", - currency = "KUDOS", - maxAmount = Amount.fromJSONString("KUDOS:100"), - wireFee = Amount.fromJSONString("KUDOS:0.2"), - possibleExchanges = listOf(), - defaultExchangeBaseUrl = null, - editableAmount = true, - ), - spec = null, - onSubmit = {}, - ) - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -1,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2020 Taler Systems S.A. + * (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 @@ -22,13 +22,17 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import net.taler.common.Amount import net.taler.common.Bech32 -import net.taler.common.Event -import net.taler.common.toEvent import net.taler.wallet.TAG import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi @@ -36,75 +40,44 @@ import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.exchanges.ExchangeFees import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails -import net.taler.wallet.withdraw.WithdrawStatus.ReceivedDetails - -sealed class WithdrawStatus { - data class Loading(val talerWithdrawUri: String? = null) : WithdrawStatus() - - data class NeedsAmount( - val talerWithdrawUri: String, - val currency: String, - val maxAmount: Amount?, - val editableAmount: Boolean, - val wireFee: Amount?, - val possibleExchanges: List<ExchangeItem>, - val defaultExchangeBaseUrl: String?, - ) : WithdrawStatus() - - data class NeedsExchange( - val talerWithdrawUri: String, - val currency: String, - val amount: Amount, - val maxAmount: Amount?, - val editableAmount: Boolean, - val wireFee: Amount?, - val possibleExchanges: List<ExchangeItem>, - ) : WithdrawStatus() - - data class TosReviewRequired( - val talerWithdrawUri: String? = null, - val exchangeBaseUrl: String, - val currency: String, - val maxAmount: Amount?, - val wireFee: Amount?, - val editableAmount: Boolean, - val amountRaw: Amount, - val amountEffective: Amount, - val withdrawalAccountList: List<WithdrawalExchangeAccountDetails>, - val ageRestrictionOptions: List<Int>? = null, - val tosText: String, - val tosEtag: String, - val showImmediately: Event<Boolean>, - val possibleExchanges: List<ExchangeItem> = emptyList(), - ) : WithdrawStatus() - - data class ReceivedDetails( - val talerWithdrawUri: String? = null, - val currency: String, - val maxAmount: Amount?, - val wireFee: Amount?, - val editableAmount: Boolean, - val exchangeBaseUrl: String, - val amountRaw: Amount, - val amountEffective: Amount, - val withdrawalAccountList: List<WithdrawalExchangeAccountDetails>, - val ageRestrictionOptions: List<Int>? = null, - val possibleExchanges: List<ExchangeItem> = emptyList(), - ) : WithdrawStatus() - - data object Withdrawing : WithdrawStatus() - - data class Success(val currency: String, val transactionId: String) : WithdrawStatus() - - class ManualTransferRequired( - val transactionId: String?, - val transactionAmountRaw: Amount, - val transactionAmountEffective: Amount, - val exchangeBaseUrl: String, - val withdrawalTransfers: List<TransferData>, - ) : WithdrawStatus() - - data class Error(val message: String?) : WithdrawStatus() +import net.taler.wallet.withdraw.WithdrawStatus.Status.* + +sealed class TestWithdrawStatus { + data object None : TestWithdrawStatus() + data object Withdrawing : TestWithdrawStatus() + data object Success : TestWithdrawStatus() + data class Error(val message: String) : TestWithdrawStatus() +} + +data class WithdrawStatus( + val status: Status = None, + + // common details + val talerWithdrawUri: String? = null, + val exchangeBaseUrl: String? = null, + val transactionId: String? = null, + val error: TalerErrorInfo? = null, + + // received details + val currency: String? = null, + val uriInfo: WithdrawalDetailsForUri? = null, + val amountInfo: WithdrawalDetailsForAmount? = null, + val tosDetails: TosResponse? = null, + + // manual transfer + val manualTransferResponse: AcceptManualWithdrawalResponse? = null, + val withdrawalTransfers: List<TransferData> = emptyList(), +) { + enum class Status { + None, + Loading, + Updating, + InfoReceived, + TosReviewRequired, + ManualTransferRequired, + Success, + Error, + } } sealed class TransferData { @@ -143,40 +116,61 @@ sealed class TransferData { ): TransferData() } -sealed class WithdrawTestStatus { - object Withdrawing : WithdrawTestStatus() - object Success : WithdrawTestStatus() - data class Error(val message: String) : WithdrawTestStatus() -} - @Serializable data class WithdrawalDetailsForUri( val amount: Amount?, val currency: String, - val editableAmount: Boolean, - val maxAmount: Amount?, - val wireFee: Amount?, - val defaultExchangeBaseUrl: String?, - val possibleExchanges: List<ExchangeItem>, -) - -@Serializable -data class WithdrawExchangeResponse( - val exchangeBaseUrl: String, - val amount: Amount? = null, + val editableAmount: Boolean = false, + val maxAmount: Amount? = null, + val wireFee: Amount? = null, + val defaultExchangeBaseUrl: String? = null, + val possibleExchanges: List<ExchangeItem> = emptyList(), ) @Serializable -data class ManualWithdrawalDetails( +data class WithdrawalDetailsForAmount( + /** + * Did the user accept the current version of the exchange's + * terms of service? + * + * @deprecated the client should query the exchange entry instead + */ val tosAccepted: Boolean, + + /** + * Amount that the user will transfer to the exchange. + */ val amountRaw: Amount, + + /** + * Amount that will be added to the user's wallet balance. + */ val amountEffective: Amount, + + /** + * Ways to pay the exchange, including accounts that require currency conversion. + */ val withdrawalAccountsList: List<WithdrawalExchangeAccountDetails>, + + /** + * If the exchange supports age-restricted coins it will return + * the array of ages. + */ val ageRestrictionOptions: List<Int>? = null, + + /** + * Scope info of the currency withdrawn. + */ val scopeInfo: ScopeInfo, ) @Serializable +data class WithdrawExchangeResponse( + val exchangeBaseUrl: String, + val amount: Amount? = null, +) + +@Serializable data class AcceptWithdrawalResponse( val transactionId: String, ) @@ -214,9 +208,11 @@ class WithdrawManager( private val api: WalletBackendApi, private val scope: CoroutineScope, ) { + private val _withdrawStatus = MutableStateFlow(WithdrawStatus()) + val withdrawStatus: StateFlow<WithdrawStatus> = _withdrawStatus.asStateFlow() - val withdrawStatus = MutableLiveData<WithdrawStatus>() - val testWithdrawalStatus = MutableLiveData<WithdrawTestStatus>() + private val _withdrawTestStatus = MutableStateFlow<TestWithdrawStatus>(TestWithdrawStatus.None) + val withdrawTestStatus: StateFlow<TestWithdrawStatus> = _withdrawTestStatus.asStateFlow() val qrCodes = MutableLiveData<List<QrCodeSpec>>() @@ -224,182 +220,83 @@ class WithdrawManager( private set fun withdrawTestkudos() = scope.launch { - testWithdrawalStatus.value = WithdrawTestStatus.Withdrawing + _withdrawTestStatus.value = TestWithdrawStatus.Withdrawing api.request<Unit>("withdrawTestkudos").onError { - testWithdrawalStatus.value = WithdrawTestStatus.Error(it.userFacingMsg) + _withdrawTestStatus.value = TestWithdrawStatus.Error(it.userFacingMsg) }.onSuccess { - testWithdrawalStatus.value = WithdrawTestStatus.Success + _withdrawTestStatus.value = TestWithdrawStatus.Success } } + @UiThread + fun resetWithdrawal() { + _withdrawStatus.value = WithdrawStatus() + } + + @UiThread + fun resetTestWithdrawal() { + _withdrawTestStatus.value = TestWithdrawStatus.None + } + fun getWithdrawalDetails(uri: String) = scope.launch { - withdrawStatus.value = WithdrawStatus.Loading(uri) + _withdrawStatus.update { + WithdrawStatus( + talerWithdrawUri = uri, + status = Loading, + ) + } + api.request("getWithdrawalDetailsForUri", WithdrawalDetailsForUri.serializer()) { put("talerWithdrawUri", uri) }.onError { error -> handleError("getWithdrawalDetailsForUri", error) }.onSuccess { details -> Log.d(TAG, "Withdraw details: $details") - - if (details.amount == null) { - withdrawStatus.value = WithdrawStatus.NeedsAmount( - talerWithdrawUri = uri, - wireFee = details.wireFee, - maxAmount = details.maxAmount, - editableAmount = details.editableAmount, - currency = details.currency, - possibleExchanges = details.possibleExchanges, - defaultExchangeBaseUrl = details.defaultExchangeBaseUrl, - ) - } else if (details.defaultExchangeBaseUrl == null) { - withdrawStatus.value = WithdrawStatus.NeedsExchange( - talerWithdrawUri = uri, + _withdrawStatus.update { value -> + value.copy( + status = InfoReceived, + uriInfo = details, currency = details.currency, - amount = details.amount, - possibleExchanges = details.possibleExchanges, - maxAmount = details.maxAmount, - wireFee = details.wireFee, - editableAmount = details.editableAmount, + exchangeBaseUrl = details.defaultExchangeBaseUrl, ) - } else getWithdrawalDetails( - exchangeBaseUrl = details.defaultExchangeBaseUrl, - currency = details.currency, - amount = details.amount, - maxAmount = details.maxAmount, - wireFee = details.wireFee, - editableAmount = details.editableAmount, - showTosImmediately = false, - uri = uri, - possibleExchanges = details.possibleExchanges, - ) + } } } fun getWithdrawalDetails( - exchangeBaseUrl: String, - currency: String, - amount: Amount, - maxAmount: Amount? = null, - wireFee: Amount? = null, - editableAmount: Boolean = false, - showTosImmediately: Boolean = false, - uri: String? = null, - possibleExchanges: List<ExchangeItem> = emptyList(), + amount: Amount? = null, + exchangeBaseUrl: String? = null, + uriInfo: WithdrawalDetailsForUri? = null, + loading: Boolean = true, ) = scope.launch { - withdrawStatus.value = WithdrawStatus.Loading(uri) - api.request("getWithdrawalDetailsForAmount", ManualWithdrawalDetails.serializer()) { - put("exchangeBaseUrl", exchangeBaseUrl) - put("amount", amount.toJSONString()) + val status = _withdrawStatus.getAndUpdate { value -> + value.copy(status = if (loading) Loading else Updating) + } + val exchangeBaseUrl2 = exchangeBaseUrl ?: status.exchangeBaseUrl!! + val amount2 = amount?.toJSONString() ?: status.amountInfo!!.amountRaw.toJSONString() + api.request("getWithdrawalDetailsForAmount", WithdrawalDetailsForAmount.serializer()) { + put("exchangeBaseUrl", exchangeBaseUrl2) + put("amount", amount2) }.onError { error -> handleError("getWithdrawalDetailsForAmount", error) }.onSuccess { details -> if (details.tosAccepted) { - withdrawStatus.value = ReceivedDetails( - talerWithdrawUri = uri, - currency = currency, - exchangeBaseUrl = exchangeBaseUrl, - amountRaw = details.amountRaw, - amountEffective = details.amountEffective, - withdrawalAccountList = details.withdrawalAccountsList, - ageRestrictionOptions = details.ageRestrictionOptions, - possibleExchanges = possibleExchanges, - maxAmount = maxAmount, - wireFee = wireFee, - editableAmount = editableAmount, - ) - } else getExchangeTos( - exchangeBaseUrl = exchangeBaseUrl, - currency = currency, - details = details, - showImmediately = showTosImmediately, - uri = uri, - possibleExchanges = possibleExchanges, - maxAmount = maxAmount, - wireFee = wireFee, - editableAmount = editableAmount, - ) + _withdrawStatus.update { value -> + value.copy( + status = InfoReceived, + exchangeBaseUrl = exchangeBaseUrl2, + uriInfo = uriInfo, + amountInfo = details, + currency = details.amountRaw.currency, + ) + } + } else getExchangeTos(exchangeBaseUrl2) } } - fun selectWithdrawalAmount(amount: Amount) { - val s = withdrawStatus.value - - val details = when (s) { - is WithdrawStatus.NeedsExchange -> WithdrawalDetailsForUri( - defaultExchangeBaseUrl = null, - currency = s.currency, - amount = amount, - possibleExchanges = s.possibleExchanges, - maxAmount = s.maxAmount, - wireFee = s.wireFee, - editableAmount = s.editableAmount, - ) - is WithdrawStatus.NeedsAmount -> WithdrawalDetailsForUri( - defaultExchangeBaseUrl = s.defaultExchangeBaseUrl, - currency = s.currency, - amount = amount, - possibleExchanges = s.possibleExchanges, - maxAmount = s.maxAmount, - wireFee = s.wireFee, - editableAmount = s.editableAmount, - ) - is ReceivedDetails -> WithdrawalDetailsForUri( - defaultExchangeBaseUrl = s.exchangeBaseUrl, - currency = s.currency, - amount = amount, - possibleExchanges = s.possibleExchanges, - maxAmount = s.maxAmount, - wireFee = s.wireFee, - editableAmount = s.editableAmount, - ) - is WithdrawStatus.TosReviewRequired -> WithdrawalDetailsForUri( - defaultExchangeBaseUrl = s.exchangeBaseUrl, - currency = s.currency, - amount = amount, - possibleExchanges = s.possibleExchanges, - maxAmount = s.maxAmount, - wireFee = s.wireFee, - editableAmount = s.editableAmount, - ) - else -> return - } - - val uri = when(s) { - is WithdrawStatus.NeedsExchange -> s.talerWithdrawUri - is WithdrawStatus.NeedsAmount -> s.talerWithdrawUri - is ReceivedDetails -> s.talerWithdrawUri - is WithdrawStatus.TosReviewRequired -> s.talerWithdrawUri - else -> return - } - - if (details.defaultExchangeBaseUrl == null) { - if (uri != null) { - withdrawStatus.value = WithdrawStatus.NeedsExchange( - talerWithdrawUri = uri, - currency = details.currency, - amount = amount, - possibleExchanges = details.possibleExchanges, - maxAmount = details.maxAmount, - wireFee = details.wireFee, - editableAmount = details.editableAmount - ) - } - } else getWithdrawalDetails( - exchangeBaseUrl = details.defaultExchangeBaseUrl, - currency = details.currency, - amount = amount, - maxAmount = details.maxAmount, - wireFee = details.wireFee, - editableAmount = details.editableAmount, - showTosImmediately = false, - uri = uri, - possibleExchanges =details.possibleExchanges, - ) - } - @WorkerThread suspend fun prepareManualWithdrawal(uri: String): WithdrawExchangeResponse? { - withdrawStatus.postValue(WithdrawStatus.Loading(uri)) + _withdrawStatus.value = WithdrawStatus(status = Loading) var response: WithdrawExchangeResponse? = null api.request("prepareWithdrawExchange", WithdrawExchangeResponse.serializer()) { put("talerUri", uri) @@ -413,70 +310,45 @@ class WithdrawManager( private fun getExchangeTos( exchangeBaseUrl: String, - currency: String, - details: ManualWithdrawalDetails, - showImmediately: Boolean, - uri: String?, - possibleExchanges: List<ExchangeItem>, - maxAmount: Amount?, - wireFee: Amount?, - editableAmount: Boolean, ) = scope.launch { api.request("getExchangeTos", TosResponse.serializer()) { put("exchangeBaseUrl", exchangeBaseUrl) }.onError { handleError("getExchangeTos", it) - }.onSuccess { - withdrawStatus.value = WithdrawStatus.TosReviewRequired( - talerWithdrawUri = uri, - exchangeBaseUrl = exchangeBaseUrl, - currency = currency, - amountRaw = details.amountRaw, - amountEffective = details.amountEffective, - withdrawalAccountList = details.withdrawalAccountsList, - ageRestrictionOptions = details.ageRestrictionOptions, - tosText = it.content, - tosEtag = it.currentEtag, - showImmediately = showImmediately.toEvent(), - possibleExchanges = possibleExchanges, - maxAmount = maxAmount, - wireFee = wireFee, - editableAmount = editableAmount, - ) + }.onSuccess { tos -> + _withdrawStatus.update { value -> + value.copy( + status = TosReviewRequired, + tosDetails = tos, + ) + } } } /** * Accept the currently displayed terms of service. */ - fun acceptCurrentTermsOfService() = scope.launch { - val s = withdrawStatus.value as WithdrawStatus.TosReviewRequired + fun acceptCurrentTos() = scope.launch { + val exchangeBaseUrl = withdrawStatus.value.exchangeBaseUrl!! + val tos = withdrawStatus.value.tosDetails!! api.request<Unit>("setExchangeTosAccepted") { - put("exchangeBaseUrl", s.exchangeBaseUrl) - put("etag", s.tosEtag) - }.onError { - handleError("setExchangeTosAccepted", it) + put("exchangeBaseUrl", exchangeBaseUrl) + put("etag", tos.currentEtag) + }.onError { error -> + handleError("setExchangeTosAccepted", error) }.onSuccess { - withdrawStatus.value = ReceivedDetails( - talerWithdrawUri = s.talerWithdrawUri, - currency = s.currency, - exchangeBaseUrl = s.exchangeBaseUrl, - amountRaw = s.amountRaw, - amountEffective = s.amountEffective, - withdrawalAccountList = s.withdrawalAccountList, - ageRestrictionOptions = s.ageRestrictionOptions, - possibleExchanges = s.possibleExchanges, - maxAmount = s.maxAmount, - wireFee = s.wireFee, - editableAmount = s.editableAmount, - ) + _withdrawStatus.update { value -> + value.copy(status = InfoReceived) + } } } @UiThread fun acceptWithdrawal(restrictAge: Int? = null) = scope.launch { - val status = withdrawStatus.value as ReceivedDetails - withdrawStatus.value = WithdrawStatus.Withdrawing + val status = _withdrawStatus.updateAndGet { value -> + value.copy(status = Loading) + } + if (status.talerWithdrawUri == null) { acceptManualWithdrawal(status, restrictAge) } else { @@ -485,35 +357,44 @@ class WithdrawManager( } private suspend fun acceptBankIntegratedWithdrawal( - status: ReceivedDetails, + status: WithdrawStatus, restrictAge: Int? = null, ) { + val exchangeBaseUrl = status.exchangeBaseUrl!! + val talerWithdrawUri = status.talerWithdrawUri!! + val amountInfo = status.amountInfo!! api.request("acceptBankIntegratedWithdrawal", AcceptWithdrawalResponse.serializer()) { - restrictAge?.let { put("restrictAge", restrictAge) } - put("exchangeBaseUrl", status.exchangeBaseUrl) - put("talerWithdrawUri", status.talerWithdrawUri) - put("amount", status.amountRaw.toJSONString()) - }.onError { - handleError("acceptBankIntegratedWithdrawal", it) - }.onSuccess { - withdrawStatus.value = - WithdrawStatus.Success(status.amountRaw.currency, it.transactionId) + restrictAge?.let { put("restrictAge", it) } + put("exchangeBaseUrl", exchangeBaseUrl) + put("talerWithdrawUri", talerWithdrawUri) + put("amount", amountInfo.amountRaw.toJSONString()) + }.onError { error -> + handleError("acceptBankIntegratedWithdrawal", error) + }.onSuccess { response -> + _withdrawStatus.update { value -> + value.copy( + status = Success, + transactionId = response.transactionId, + ) + } } } - private suspend fun acceptManualWithdrawal(status: ReceivedDetails, restrictAge: Int? = null) { + private suspend fun acceptManualWithdrawal( + status: WithdrawStatus, + restrictAge: Int? = null, + ) { + val exchangeBaseUrl = status.exchangeBaseUrl!! + val amountInfo = status.amountInfo!! api.request("acceptManualWithdrawal", AcceptManualWithdrawalResponse.serializer()) { - restrictAge?.let { put("restrictAge", restrictAge) } - put("exchangeBaseUrl", status.exchangeBaseUrl) - put("amount", status.amountRaw.toJSONString()) - }.onError { - handleError("acceptManualWithdrawal", it) + restrictAge?.let { put("restrictAge", it) } + put("exchangeBaseUrl", exchangeBaseUrl) + put("amount", amountInfo.amountRaw.toJSONString()) + }.onError { error -> + handleError("acceptManualWithdrawal", error) }.onSuccess { response -> - scope.launch { - withdrawStatus.value = createManualTransferRequired( - status = status, - response = response, - ) + _withdrawStatus.update { value -> + createManualTransfer(value, response) } } } @@ -533,75 +414,85 @@ class WithdrawManager( private fun handleError(operation: String, error: TalerErrorInfo) { Log.e(TAG, "Error $operation $error") - withdrawStatus.postValue(WithdrawStatus.Error(error.userFacingMsg)) + _withdrawStatus.update { value -> + value.copy(status = Error, error = error) + } } /** * A hack to be able to view bank details for manual withdrawal with the same logic. * Don't call this from ongoing withdrawal processes as it destroys state. */ - fun viewManualWithdrawal(status: WithdrawStatus.ManualTransferRequired) { - require(status.transactionId != null) { "No transaction ID given" } - withdrawStatus.value = status - } - -} - -fun createManualTransferRequired( - transactionId: String, - exchangeBaseUrl: String, - amountRaw: Amount, - amountEffective: Amount, - withdrawalAccountList: List<WithdrawalExchangeAccountDetails>, -) = WithdrawStatus.ManualTransferRequired( - transactionId = transactionId, - transactionAmountRaw = amountRaw, - transactionAmountEffective = amountEffective, - exchangeBaseUrl = exchangeBaseUrl, - withdrawalTransfers = withdrawalAccountList.mapNotNull { - val uri = Uri.parse(it.paytoUri.replace("receiver-name=", "receiver_name=")) - if ("bitcoin".equals(uri.authority, true)) { - val msg = uri.getQueryParameter("message").orEmpty() - val reg = "\\b([A-Z0-9]{52})\\b".toRegex().find(msg) - val reserve = reg?.value ?: uri.getQueryParameter("subject")!! - val segwitAddresses = Bech32.generateFakeSegwitAddress(reserve, uri.pathSegments.first()) - TransferData.Bitcoin( - account = uri.lastPathSegment!!, - segwitAddresses = segwitAddresses, - subject = reserve, - amountRaw = amountRaw, - amountEffective = amountEffective, - withdrawalAccount = it.copy(paytoUri = uri.toString()), - ) - } else if (uri.authority.equals("x-taler-bank", true)) { - TransferData.Taler( - account = uri.lastPathSegment!!, - receiverName = uri.getQueryParameter("receiver_name"), - subject = uri.getQueryParameter("message") ?: "Error: No message in URI", - amountRaw = amountRaw, - amountEffective = amountEffective, - withdrawalAccount = it.copy(paytoUri = uri.toString()), - ) - } else if (uri.authority.equals("iban", true)) { - TransferData.IBAN( - iban = uri.lastPathSegment!!, - receiverName = uri.getQueryParameter("receiver_name"), - subject = uri.getQueryParameter("message") ?: "Error: No message in URI", - amountRaw = amountRaw, - amountEffective = amountEffective, - withdrawalAccount = it.copy(paytoUri = uri.toString()), + fun viewManualWithdrawal( + transactionId: String, + exchangeBaseUrl: String, + amountRaw: Amount, + amountEffective: Amount, + withdrawalAccountList: List<WithdrawalExchangeAccountDetails>, + scopeInfo: ScopeInfo, + ) { + _withdrawStatus.value = createManualTransfer( + status = WithdrawStatus( + transactionId = transactionId, + exchangeBaseUrl = exchangeBaseUrl, + amountInfo = WithdrawalDetailsForAmount( + amountRaw = amountRaw, + amountEffective = amountEffective, + withdrawalAccountsList = withdrawalAccountList, + scopeInfo = scopeInfo, + tosAccepted = true, + ) + ), + response = AcceptManualWithdrawalResponse( + transactionId = transactionId, + reservePub = "", + withdrawalAccountsList = withdrawalAccountList, ) - } else null - }, -) + ) + } -fun createManualTransferRequired( - status: ReceivedDetails, - response: AcceptManualWithdrawalResponse, -): WithdrawStatus.ManualTransferRequired = createManualTransferRequired( - transactionId = response.transactionId, - exchangeBaseUrl = status.exchangeBaseUrl, - amountRaw = status.amountRaw, - amountEffective = status.amountEffective, - withdrawalAccountList = response.withdrawalAccountsList, -) -\ No newline at end of file + private fun createManualTransfer( + status: WithdrawStatus, + response: AcceptManualWithdrawalResponse, + ) = status.copy( + status = ManualTransferRequired, + manualTransferResponse = response, + withdrawalTransfers = response.withdrawalAccountsList.mapNotNull { + val details = status.amountInfo!! + val uri = Uri.parse(it.paytoUri.replace("receiver-name=", "receiver_name=")) + if ("bitcoin".equals(uri.authority, true)) { + val msg = uri.getQueryParameter("message").orEmpty() + val reg = "\\b([A-Z0-9]{52})\\b".toRegex().find(msg) + val reserve = reg?.value ?: uri.getQueryParameter("subject")!! + val segwitAddresses = + Bech32.generateFakeSegwitAddress(reserve, uri.pathSegments.first()) + TransferData.Bitcoin( + account = uri.lastPathSegment!!, + segwitAddresses = segwitAddresses, + subject = reserve, + amountRaw = details.amountRaw, + amountEffective = details.amountEffective, + withdrawalAccount = it.copy(paytoUri = uri.toString()), + ) + } else if (uri.authority.equals("x-taler-bank", true)) { + TransferData.Taler( + account = uri.lastPathSegment!!, + receiverName = uri.getQueryParameter("receiver_name"), + subject = uri.getQueryParameter("message") ?: "Error: No message in URI", + amountRaw = details.amountRaw, + amountEffective = details.amountEffective, + withdrawalAccount = it.copy(paytoUri = uri.toString()), + ) + } else if (uri.authority.equals("iban", true)) { + TransferData.IBAN( + iban = uri.lastPathSegment!!, + receiverName = uri.getQueryParameter("receiver_name"), + subject = uri.getQueryParameter("message") ?: "Error: No message in URI", + amountRaw = details.amountRaw, + amountEffective = details.amountEffective, + withdrawalAccount = it.copy(paytoUri = uri.toString()), + ) + } else null + }, + ) +} +\ 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 @@ -93,7 +93,6 @@ class ManualWithdrawFragment : Fragment() { withdrawManager.getWithdrawalDetails( exchangeBaseUrl = exchangeItem.exchangeBaseUrl, - currency = currency, amount = amount, ) findNavController().navigate(R.id.action_nav_exchange_manual_withdrawal_to_promptWithdraw) diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt @@ -40,13 +40,13 @@ class ManualWithdrawSuccessFragment : Fragment() { private val withdrawManager by lazy { model.withdrawManager } private val balanceManager by lazy { model.balanceManager } - private lateinit var status: WithdrawStatus.ManualTransferRequired + private lateinit var status: WithdrawStatus override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View = ComposeView(requireContext()).apply { - status = withdrawManager.withdrawStatus.value as WithdrawStatus.ManualTransferRequired + status = withdrawManager.withdrawStatus.value // Set action bar subtitle and unset on exit if (status.withdrawalTransfers.size > 1) { @@ -73,7 +73,7 @@ class ManualWithdrawSuccessFragment : Fragment() { status = status, qrCodes = qrCodes ?: emptyList(), getQrCodes = { withdrawManager.getQrCodesForPayto(it.paytoUri) }, - spec = balanceManager.getSpecForCurrency(status.transactionAmountRaw.currency), + spec = status.amountInfo?.amountRaw?.currency?.let { balanceManager.getSpecForCurrency(it) }, bankAppClick = { onBankAppClick(it) }, shareClick = { onShareClick(it) }, ) diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt @@ -53,6 +53,7 @@ import net.taler.common.CurrencySpecification import net.taler.wallet.CURRENCY_BTC import net.taler.wallet.R import net.taler.common.canAppHandleUri +import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.ShareButton import net.taler.wallet.compose.copyToClipBoard import net.taler.wallet.transactions.AmountType @@ -64,10 +65,11 @@ import net.taler.wallet.withdraw.QrCodeSpec.Type.EpcQr import net.taler.wallet.withdraw.QrCodeSpec.Type.SPC import net.taler.wallet.withdraw.TransferData import net.taler.wallet.withdraw.WithdrawStatus +import net.taler.wallet.withdraw.WithdrawalDetailsForAmount @Composable fun ScreenTransfer( - status: WithdrawStatus.ManualTransferRequired, + status: WithdrawStatus, qrCodes: List<QrCodeSpec>, spec: CurrencySpecification?, getQrCodes: (account: WithdrawalExchangeAccountDetails) -> Unit, @@ -123,22 +125,22 @@ fun ScreenTransfer( when (val transfer = selectedTransfer) { is TransferData.Taler -> TransferTaler( transfer = transfer, - exchangeBaseUrl = status.exchangeBaseUrl, - transactionAmountRaw = status.transactionAmountRaw.withSpec(spec), - transactionAmountEffective = status.transactionAmountEffective.withSpec(spec), + exchangeBaseUrl = status.exchangeBaseUrl!!, + transactionAmountRaw = status.amountInfo!!.amountRaw.withSpec(spec), + transactionAmountEffective = status.amountInfo.amountEffective.withSpec(spec), ) is TransferData.IBAN -> TransferIBAN( transfer = transfer, - exchangeBaseUrl = status.exchangeBaseUrl, - transactionAmountRaw = status.transactionAmountRaw.withSpec(spec), - transactionAmountEffective = status.transactionAmountEffective.withSpec(spec), + exchangeBaseUrl = status.exchangeBaseUrl!!, + transactionAmountRaw = status.amountInfo!!.amountRaw.withSpec(spec), + transactionAmountEffective = status.amountInfo.amountEffective.withSpec(spec), ) is TransferData.Bitcoin -> TransferBitcoin( transfer = transfer, - transactionAmountRaw = status.transactionAmountRaw.withSpec(spec), - transactionAmountEffective = status.transactionAmountEffective.withSpec(spec), + transactionAmountRaw = status.amountInfo!!.amountRaw.withSpec(spec), + transactionAmountEffective = status.amountInfo.amountEffective.withSpec(spec), ) } @@ -313,10 +315,15 @@ fun TransferAccountChooser( fun ScreenTransferPreview() { Surface { ScreenTransfer( - status = WithdrawStatus.ManualTransferRequired( + status = WithdrawStatus( transactionId = "", - transactionAmountRaw = Amount.fromJSONString("KUDOS:10"), - transactionAmountEffective = Amount.fromJSONString("KUDOS:9.5"), + amountInfo = WithdrawalDetailsForAmount( + amountRaw = Amount.fromJSONString("KUDOS:10"), + amountEffective = Amount.fromJSONString("KUDOS:9.5"), + scopeInfo = ScopeInfo.Global("KUDOS"), + tosAccepted = true, + withdrawalAccountsList = listOf(), + ), exchangeBaseUrl = "test.exchange.taler.net", withdrawalTransfers = listOf( TransferData.IBAN( diff --git a/wallet/src/main/res/layout/fragment_prompt_withdraw.xml b/wallet/src/main/res/layout/fragment_prompt_withdraw.xml @@ -1,287 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ This file is part of GNU Taler - ~ (C) 2020 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/> - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context=".withdraw.PromptWithdrawFragment"> - - <TextView - android:id="@+id/chosenAmountLabel" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="32dp" - android:layout_marginEnd="16dp" - android:gravity="center" - android:text="@string/amount_chosen" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/chosenAmountView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/effectiveAmountView" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_chainStyle="packed" - tools:visibility="visible" /> - - <TextView - android:id="@+id/chosenAmountView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="16dp" - android:gravity="center" - android:textSize="20sp" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/feeLabel" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/chosenAmountLabel" - tools:text="10 TESTKUDOS" - tools:visibility="visible" /> - - <ImageButton - android:id="@+id/selectAmountButton" - android:layout_width="48dp" - android:layout_height="48dp" - android:layout_marginEnd="16dp" - android:backgroundTint="@color/colorPrimary" - android:contentDescription="@string/nav_exchange_fees" - android:src="@drawable/ic_edit" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="@+id/chosenAmountView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/chosenAmountView" - app:layout_constraintTop_toTopOf="@+id/chosenAmountView" - app:tint="?attr/colorOnPrimary" - tools:visibility="visible" /> - - <TextView - android:id="@+id/feeLabel" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="32dp" - android:layout_marginEnd="16dp" - android:gravity="center" - android:text="@string/amount_fee" - android:visibility="gone" - app:layout_constraintBottom_toTopOf="@+id/feeView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/chosenAmountView" - tools:visibility="visible" /> - - <TextView - android:id="@+id/feeView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="16dp" - android:gravity="center" - android:textColor="?colorError" - android:textSize="20sp" - android:visibility="gone" - app:layout_constraintBottom_toTopOf="@+id/introView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/feeLabel" - tools:text="-0.2 TESTKUDOS" - tools:visibility="visible" /> - - <TextView - android:id="@+id/introView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="32dp" - android:layout_marginEnd="16dp" - android:layout_marginBottom="8dp" - android:gravity="center" - android:text="@string/amount_total" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/effectiveAmountView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/feeView" - tools:visibility="visible" /> - - <TextView - android:id="@+id/effectiveAmountView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginEnd="16dp" - android:gravity="center" - android:textColor="@color/green" - android:textSize="24sp" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/exchangeIntroView" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/introView" - tools:text="9.8 TESTKUDOS" - tools:visibility="visible" /> - - <TextView - android:id="@+id/exchangeIntroView" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="32dp" - android:layout_marginEnd="16dp" - android:layout_marginBottom="8dp" - android:gravity="center" - android:text="@string/withdraw_exchange" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/withdrawExchangeUrl" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/effectiveAmountView" - tools:visibility="visible" /> - - <TextView - android:id="@+id/withdrawExchangeUrl" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginEnd="8dp" - android:gravity="center" - android:textSize="24sp" - android:visibility="invisible" - app:layout_constrainedWidth="true" - app:layout_constraintBottom_toTopOf="@+id/ageLabel" - app:layout_constraintEnd_toStartOf="@+id/selectExchangeButton" - app:layout_constraintHorizontal_chainStyle="packed" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/exchangeIntroView" - tools:text="demo.taler.net" - tools:visibility="visible" /> - - <ImageButton - android:id="@+id/selectExchangeButton" - android:layout_width="48dp" - android:layout_height="48dp" - android:layout_marginEnd="16dp" - android:backgroundTint="@color/colorPrimary" - android:contentDescription="@string/nav_exchange_fees" - android:src="@drawable/ic_edit" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="@+id/withdrawExchangeUrl" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/withdrawExchangeUrl" - app:layout_constraintTop_toTopOf="@+id/withdrawExchangeUrl" - app:tint="?attr/colorOnPrimary" - tools:visibility="visible" /> - - <TextView - android:id="@+id/ageLabel" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="32dp" - android:layout_marginEnd="16dp" - android:gravity="center" - android:text="@string/withdraw_restrict_age" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/ageSelector" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/withdrawExchangeUrl" - tools:visibility="visible" /> - - <Spinner - android:id="@+id/ageSelector" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="16dp" - android:gravity="center" - android:spinnerMode="dropdown" - android:textSize="20sp" - android:visibility="invisible" - app:layout_constraintBottom_toTopOf="@+id/withdrawCard" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/ageLabel" - tools:visibility="visible" /> - - <ProgressBar - android:id="@+id/progressBar" - style="?android:attr/progressBarStyleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <com.google.android.material.card.MaterialCardView - android:id="@+id/withdrawCard" - style="@style/BottomCard" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:visibility="invisible" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - tools:visibility="visible"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="8dp"> - - <Button - android:id="@+id/confirmWithdrawButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:backgroundTint="@color/green" - android:enabled="false" - android:text="@string/withdraw_button_confirm" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintStart_toStartOf="parent" - tools:enabled="true" - tools:text="@string/withdraw_button_tos" /> - - <ProgressBar - android:id="@+id/confirmProgressBar" - style="?android:attr/progressBarStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:visibility="invisible" - app:layout_constraintBottom_toBottomOf="@+id/confirmWithdrawButton" - app:layout_constraintEnd_toEndOf="@+id/confirmWithdrawButton" - app:layout_constraintStart_toStartOf="@+id/confirmWithdrawButton" - app:layout_constraintTop_toTopOf="@+id/confirmWithdrawButton" - tools:visibility="visible" /> - - </androidx.constraintlayout.widget.ConstraintLayout> - - </com.google.android.material.card.MaterialCardView> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -56,6 +56,7 @@ 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> + <string name="edit">Edit</string> <string name="enter_uri">Enter taler:// URI</string> <string name="import_db">Import</string> <string name="loading">Loading</string> @@ -95,7 +96,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="amount_lost">Amount lost</string> <string name="amount_negative">-%s</string> <string name="amount_max">Maximum amount</string> - <string name="amount_excess">Amount exceeds maximum</string> + <string name="amount_excess">Amount exceeds maximum of %1$s</string> <string name="amount_positive">+%s</string> <string name="amount_receive">Amount to receive</string> <string name="amount_received">Amount received</string>