diff options
Diffstat (limited to 'wallet/src/main/java/net/taler/wallet/transactions')
18 files changed, 1968 insertions, 284 deletions
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/ActionButtonComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/ActionButtonComposable.kt new file mode 100644 index 0000000..4e4bbe0 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/ActionButtonComposable.kt @@ -0,0 +1,116 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.transactions + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalance +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import net.taler.wallet.R +import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionMinorState.BankConfirmTransfer +import net.taler.wallet.transactions.TransactionMinorState.ExchangeWaitReserve +import net.taler.wallet.transactions.TransactionMinorState.KycRequired + +interface ActionListener { + enum class Type { + COMPLETE_KYC, + CONFIRM_WITH_BANK, + CONFIRM_MANUAL + } + + fun onActionButtonClicked(tx: Transaction, type: Type) +} + +@Composable +fun ActionButton( + modifier: Modifier = Modifier, + tx: TransactionWithdrawal, + listener: ActionListener, +) { + if (tx.txState.major == Pending) { + when (tx.txState.minor) { + KycRequired -> KycButton(modifier, tx, listener) + BankConfirmTransfer -> ConfirmBankButton(modifier, tx, listener) + ExchangeWaitReserve -> ConfirmManualButton(modifier, tx, listener) + else -> {} + } + } +} + +@Composable +private fun KycButton( + modifier: Modifier = Modifier, + tx: TransactionWithdrawal, + listener: ActionListener, +) { + Button( + onClick = { listener.onActionButtonClicked(tx, ActionListener.Type.COMPLETE_KYC) }, + modifier = modifier, + ) { + Text(stringResource(R.string.transaction_action_kyc)) + } +} + +@Composable +private fun ConfirmBankButton( + modifier: Modifier = Modifier, + tx: TransactionWithdrawal, + listener: ActionListener, +) { + Button( + onClick = { listener.onActionButtonClicked(tx, ActionListener.Type.CONFIRM_WITH_BANK) }, + modifier = modifier, + ) { + val label = stringResource(R.string.withdraw_button_confirm_bank) + Icon( + Icons.Default.AccountBalance, + label, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(label) + } +} + +@Composable +private fun ConfirmManualButton( + modifier: Modifier = Modifier, + tx: TransactionWithdrawal, + listener: ActionListener, +) { + Button( + onClick = { listener.onActionButtonClicked(tx, ActionListener.Type.CONFIRM_MANUAL) }, + modifier = modifier, + ) { + val label = stringResource(R.string.withdraw_manual_ready_details_intro) + Icon( + Icons.Default.AccountBalance, + label, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(label) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/ErrorTransactionComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/ErrorTransactionComposable.kt new file mode 100644 index 0000000..ea875d7 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/ErrorTransactionComposable.kt @@ -0,0 +1,116 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.transactions + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.Error +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.sp +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.copyToClipBoard + +@Composable +fun ErrorTransactionButton( + modifier: Modifier = Modifier, + error: TalerErrorInfo, +) { + val showDialog = remember { mutableStateOf(false) } + + if (showDialog.value) { + @Suppress("OPT_IN_USAGE") + val json = Json { + prettyPrint = true + prettyPrintIndent = " " + } + val message = json.encodeToString(error) + AlertDialog( + onDismissRequest = { + showDialog.value = false + }, + title = { + Text(stringResource(R.string.nav_error)) + }, + text = { + val scrollState = rememberScrollState() + Column( + modifier = Modifier.verticalScroll(scrollState), + ) { + Text( + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + text = message, + ) + } + }, + dismissButton = { + TextButton(onClick = { + showDialog.value = false + }) { + Text(stringResource(R.string.close)) + } + }, + confirmButton = { + val context = LocalContext.current + TextButton(onClick = { + copyToClipBoard(context, context.getString(R.string.nav_error), message) + }) { + Text(stringResource(R.string.copy)) + } + }) + } + + Button( + modifier = modifier, + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onError, + containerColor = MaterialTheme.colorScheme.error, + ), + onClick = { + showDialog.value = true + } + ) { + val label = stringResource(R.string.nav_error) + Icon( + imageVector = Icons.Default.Error, + contentDescription = label, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(label) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt index 5e492f5..3b686a6 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt @@ -32,16 +32,25 @@ import androidx.recyclerview.selection.SelectionTracker import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.ViewHolder +import net.taler.common.CurrencySpecification import net.taler.common.exhaustive import net.taler.common.toRelativeTime import net.taler.wallet.R +import net.taler.wallet.getThemeColor import net.taler.wallet.transactions.TransactionAdapter.TransactionViewHolder +import net.taler.wallet.transactions.TransactionMajorState.Aborted +import net.taler.wallet.transactions.TransactionMajorState.Failed +import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionMinorState.BankConfirmTransfer +import net.taler.wallet.transactions.TransactionMinorState.KycRequired internal class TransactionAdapter( - private val listener: OnTransactionClickListener + private val listener: OnTransactionClickListener, ) : Adapter<TransactionViewHolder>() { private var transactions: List<Transaction> = ArrayList() + private var currencySpec: CurrencySpecification? = null + lateinit var tracker: SelectionTracker<String> val keyProvider = TransactionKeyProvider() @@ -62,6 +71,11 @@ internal class TransactionAdapter( holder.bind(transaction, tracker.isSelected(transaction.transactionId)) } + fun setCurrencySpec(spec: CurrencySpecification?) { + this.currencySpec = spec + this.notifyDataSetChanged() + } + fun update(updatedTransactions: List<Transaction>) { this.transactions = updatedTransactions this.notifyDataSetChanged() @@ -74,6 +88,7 @@ internal class TransactionAdapter( internal inner class TransactionViewHolder(private val v: View) : ViewHolder(v) { private val context: Context = v.context + private val root: ViewGroup = v.findViewById(R.id.root) private val icon: ImageView = v.findViewById(R.id.icon) private val title: TextView = v.findViewById(R.id.title) private val extraInfoView: TextView = v.findViewById(R.id.extraInfoView) @@ -82,12 +97,12 @@ internal class TransactionAdapter( private val pendingView: TextView = v.findViewById(R.id.pendingView) private val amountColor = amount.currentTextColor - private val red = getColor(context, R.color.red) + private val extraInfoColor = extraInfoView.currentTextColor + private val red = context.getThemeColor(R.attr.colorError) private val green = getColor(context, R.color.green) fun bind(transaction: Transaction, selected: Boolean) { v.setOnClickListener { listener.onTransactionClicked(transaction) } - v.isActivated = selected if (transaction.error == null) { icon.setImageResource(transaction.icon) } else { @@ -97,39 +112,98 @@ internal class TransactionAdapter( bindExtraInfo(transaction) time.text = transaction.timestamp.ms.toRelativeTime(context) bindAmount(transaction) - pendingView.visibility = if (transaction.pending) VISIBLE else GONE + pendingView.visibility = if (transaction.txState.major == Pending) VISIBLE else GONE + val bgColor = getColor( + context, + if (selected) R.color.selectedBackground + else android.R.color.transparent + ) + root.setBackgroundColor(bgColor) } private fun bindExtraInfo(transaction: Transaction) { - if (transaction.error != null) { - extraInfoView.text = - context.getString(R.string.payment_error, transaction.error!!.userFacingMsg) - extraInfoView.setTextColor(red) - extraInfoView.visibility = VISIBLE - } else if (transaction is TransactionWithdrawal && !transaction.confirmed) { - extraInfoView.setText(R.string.withdraw_waiting_confirm) - extraInfoView.setTextColor(amountColor) - extraInfoView.visibility = VISIBLE - } else if (transaction is TransactionPayment && transaction.status != PaymentStatus.Paid && transaction.status != PaymentStatus.Accepted) { - extraInfoView.setText(if (transaction.status == PaymentStatus.Aborted) R.string.payment_aborted else R.string.payment_failed) - extraInfoView.setTextColor(amountColor) - extraInfoView.visibility = VISIBLE - } else { - extraInfoView.visibility = GONE + when { + // Goes first so it always shows errors when present + transaction.error != null -> { + extraInfoView.text = + context.getString(R.string.payment_error, transaction.error!!.userFacingMsg) + extraInfoView.setTextColor(red) + extraInfoView.visibility = VISIBLE + } + + transaction.txState.major == Aborted -> { + extraInfoView.setText(R.string.payment_aborted) + extraInfoView.setTextColor(red) + extraInfoView.visibility = VISIBLE + } + + transaction.txState.major == Failed -> { + extraInfoView.setText(R.string.payment_failed) + extraInfoView.setTextColor(red) + extraInfoView.visibility = VISIBLE + } + + transaction.txState.major == Pending -> when (transaction.txState.minor) { + BankConfirmTransfer -> { + extraInfoView.setText(R.string.withdraw_waiting_confirm) + extraInfoView.setTextColor(amountColor) + extraInfoView.visibility = VISIBLE + } + KycRequired -> { + extraInfoView.setText(R.string.transaction_action_kyc) + extraInfoView.setTextColor(amountColor) + extraInfoView.visibility = VISIBLE + } + else -> extraInfoView.visibility = GONE + } + + transaction is TransactionWithdrawal && !transaction.confirmed -> { + extraInfoView.setText(R.string.withdraw_waiting_confirm) + extraInfoView.setTextColor(amountColor) + extraInfoView.visibility = VISIBLE + } + + transaction is TransactionPeerPushCredit && transaction.info.summary != null -> { + extraInfoView.text = transaction.info.summary + extraInfoView.setTextColor(extraInfoColor) + extraInfoView.visibility = VISIBLE + } + + transaction is TransactionPeerPushDebit && transaction.info.summary != null -> { + extraInfoView.text = transaction.info.summary + extraInfoView.setTextColor(extraInfoColor) + extraInfoView.visibility = VISIBLE + } + + transaction is TransactionPeerPullCredit && transaction.info.summary != null -> { + extraInfoView.text = transaction.info.summary + extraInfoView.setTextColor(extraInfoColor) + extraInfoView.visibility = VISIBLE + } + + transaction is TransactionPeerPullDebit && transaction.info.summary != null -> { + extraInfoView.text = transaction.info.summary + extraInfoView.setTextColor(extraInfoColor) + extraInfoView.visibility = VISIBLE + } + + else -> extraInfoView.visibility = GONE } } private fun bindAmount(transaction: Transaction) { - val amountStr = transaction.amountEffective.amountStr + val amountStr = transaction.amountEffective.withSpec(currencySpec).toString(showSymbol = false) when (transaction.amountType) { AmountType.Positive -> { amount.text = context.getString(R.string.amount_positive, amountStr) - amount.setTextColor(if (transaction.pending) amountColor else green) + amount.setTextColor(if (transaction.txState.major == Pending) amountColor else green) } + AmountType.Negative -> { amount.text = context.getString(R.string.amount_negative, amountStr) - amount.setTextColor(if (transaction.pending) amountColor else red) + amount.setTextColor(if (transaction.txState.major == Pending) amountColor else red) } + AmountType.Neutral -> { amount.text = amountStr amount.setTextColor(amountColor) @@ -149,12 +223,13 @@ internal class TransactionAdapter( internal class TransactionLookup( private val list: RecyclerView, - private val adapter: TransactionAdapter + private val adapter: TransactionAdapter, ) : ItemDetailsLookup<String>() { override fun getItemDetails(e: MotionEvent): ItemDetails<String>? { list.findChildViewUnder(e.x, e.y)?.let { view -> val holder = list.getChildViewHolder(view) - val position = holder.adapterPosition + val position = holder.bindingAdapterPosition + if (position < 0) return null return object : ItemDetails<String>() { override fun getPosition(): Int = position override fun getSelectionKey(): String = adapter.keyProvider.getKey(position) diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt new file mode 100644 index 0000000..d2be3cf --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt @@ -0,0 +1,48 @@ +/* + * 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.transactions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.deposit.TransactionDepositComposable + +class TransactionDepositFragment : TransactionDetailFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val t = transactionManager.selectedTransaction.observeAsState().value + if (t is TransactionDeposit) TransactionDepositComposable( + t = t, + devMode = devMode, + spec = balanceManager.getSpecForCurrency(t.amountRaw.currency), + ) { + onTransitionButtonClicked(t, it) + } + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt index 866b363..09ca05b 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailFragment.kt @@ -16,73 +16,151 @@ package net.taler.wallet.transactions -import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.widget.TextView +import android.util.Log +import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import net.taler.common.startActivitySafe -import net.taler.lib.common.Amount +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import net.taler.common.showError import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.TAG +import net.taler.wallet.showError +import net.taler.wallet.transactions.TransactionAction.Abort +import net.taler.wallet.transactions.TransactionAction.Delete +import net.taler.wallet.transactions.TransactionAction.Fail +import net.taler.wallet.transactions.TransactionAction.Resume +import net.taler.wallet.transactions.TransactionAction.Retry +import net.taler.wallet.transactions.TransactionAction.Suspend abstract class TransactionDetailFragment : Fragment() { private val model: MainViewModel by activityViewModels() - private val transactionManager by lazy { model.transactionManager } - protected val transaction: Transaction? get() = transactionManager.selectedTransaction + protected val transactionManager by lazy { model.transactionManager } + protected val balanceManager by lazy { model.balanceManager } + protected val devMode get() = model.devMode.value == true - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(model.devMode.value == true) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + transactionManager.selectedTransaction.observe(viewLifecycleOwner) { + requireActivity().apply { + it?.generalTitleRes?.let { + title = getString(it) + } + } + } + } + + private fun dialogTitle(t: TransactionAction): Int = when (t) { + Delete -> R.string.transactions_delete_dialog_title + Abort -> R.string.transactions_abort_dialog_title + Fail -> R.string.transactions_fail_dialog_title + else -> error("unsupported action: $t") + } + + private fun dialogMessage(t: TransactionAction): Int = when (t) { + Delete -> R.string.transactions_delete_dialog_message + Abort -> R.string.transactions_abort_dialog_message + Fail -> R.string.transactions_fail_dialog_message + else -> error("unsupported action: $t") } - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - requireActivity().apply { - transaction?.generalTitleRes?.let { - title = getString(it) + private fun dialogButton(t: TransactionAction): Int = when (t) { + Delete -> R.string.transactions_delete + Abort -> R.string.transactions_abort + Fail -> R.string.transactions_fail + else -> error("unsupported") + } + + protected fun onTransitionButtonClicked(t: Transaction, ta: TransactionAction) = when (ta) { + Delete -> showDialog(ta) { deleteTransaction(t) } + Abort -> showDialog(ta) { abortTransaction(t) } + Fail -> showDialog(ta) { failTransaction(t) } + Retry -> retryTransaction(t) + Suspend -> suspendTransaction(t) + Resume -> resumeTransaction(t) + } + + private fun showDialog(tt: TransactionAction, onAction: () -> Unit) { + MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) + .setTitle(dialogTitle(tt)) + .setMessage(dialogMessage(tt)) + .setNeutralButton(R.string.cancel) { dialog, _ -> + dialog.cancel() + } + .setNegativeButton(dialogButton(tt)) { dialog, _ -> + onAction() + dialog.dismiss() + } + .show() + } + + private fun deleteTransaction(t: Transaction) { + transactionManager.deleteTransaction(t.transactionId) { + Log.e(TAG, "Error deleteTransaction $it") + if (model.devMode.value == true) { + showError(it) + } else { + showError(it.userFacingMsg) } } + findNavController().popBackStack() } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.transactions_detail, menu) + private fun retryTransaction(t: Transaction) { + transactionManager.retryTransaction(t.transactionId) { + Log.e(TAG, "Error retryTransaction $it") + if (model.devMode.value == true) { + showError(it) + } else { + showError(it.userFacingMsg) + } + } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - else -> super.onOptionsItemSelected(item) + private fun abortTransaction(t: Transaction) { + transactionManager.abortTransaction(t.transactionId) { + Log.e(TAG, "Error abortTransaction $it") + if (model.devMode.value == true) { + showError(it) + } else { + showError(it.userFacingMsg) + } } } - protected fun bindOrderAndFee( - orderSummaryView: TextView, - orderAmountView: TextView, - orderIdView: TextView, - feeView: TextView, - info: TransactionInfo, - raw: Amount, - fee: Amount - ) { - orderAmountView.text = raw.toString() - feeView.text = getString(R.string.amount_negative, fee.toString()) - orderSummaryView.text = if (info.fulfillmentMessage == null) { - info.summary - } else { - "${info.summary}\n\n${info.fulfillmentMessage}" + private fun failTransaction(t: Transaction) { + transactionManager.failTransaction(t.transactionId) { + Log.e(TAG, "Error failTransaction $it") + if (model.devMode.value == true) { + showError(it) + } else { + showError(it.userFacingMsg) + } } - if (info.fulfillmentUrl?.startsWith("http") == true) { - val i = Intent().apply { - data = Uri.parse(info.fulfillmentUrl) + } + + private fun suspendTransaction(t: Transaction) { + transactionManager.suspendTransaction(t.transactionId) { + Log.e(TAG, "Error suspendTransaction $it") + if (model.devMode.value == true) { + showError(it) + } else { + showError(it.userFacingMsg) } - orderSummaryView.setOnClickListener { startActivitySafe(i) } } - orderIdView.text = getString(R.string.transaction_order_id, info.orderId) } + private fun resumeTransaction(t: Transaction) { + transactionManager.resumeTransaction(t.transactionId) { + Log.e(TAG, "Error resumeTransaction $it") + if (model.devMode.value == true) { + showError(it) + } else { + showError(it.userFacingMsg) + } + } + } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt new file mode 100644 index 0000000..ad5e19c --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt @@ -0,0 +1,64 @@ +/* + * 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.transactions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import net.taler.wallet.compose.TalerSurface + +class TransactionDummyFragment : TransactionDetailFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val t = transactionManager.selectedTransaction.observeAsState(null).value + if (t is DummyTransaction) TransactionDummyComposable(t) + } + } + } +} + +@Composable +fun TransactionDummyComposable(t: DummyTransaction) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(scrollState), + horizontalAlignment = CenterHorizontally, + ) { + ErrorTransactionButton(error = t.error) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLinkComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLinkComposable.kt new file mode 100644 index 0000000..e8fca0f --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLinkComposable.kt @@ -0,0 +1,86 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.transactions + +import android.R +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.getAttrColor + +@Composable +// FIXME this assumes that it is used in a column and applies its own padding, not really re-usable +fun TransactionLinkComposable(label: String, info: String, onClick: () -> Unit) { + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + val context = LocalContext.current + val linkColor = Color(context.getAttrColor(R.attr.textColorLink)) + val annotatedString = buildAnnotatedString { + pushStringAnnotation(tag = "url", annotation = info) + withStyle(style = SpanStyle(color = linkColor)) { + append(info) + } + pop() + } + ClickableText( + modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + text = annotatedString, + style = TextStyle(fontSize = 24.sp, textAlign = TextAlign.Center), + ) { offset -> + annotatedString.getStringAnnotations( + tag = "url", + start = offset, + end = offset, + ).firstOrNull()?.let { + onClick() + } + } +} + +@Preview +@Composable +fun TransactionLinkComposablePreview() { + TalerSurface { + Column( + horizontalAlignment = CenterHorizontally, + ) { + TransactionLinkComposable( + label = "This is a label", + info = "This is some fulfillment message" + ) {} + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt new file mode 100644 index 0000000..9138345 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt @@ -0,0 +1,163 @@ +/* + * 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.transactions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.Timestamp +import net.taler.common.toAbsoluteTime +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.transactions.LossEventType.DenomExpired +import net.taler.wallet.transactions.LossEventType.DenomUnoffered +import net.taler.wallet.transactions.LossEventType.DenomVanished +import net.taler.wallet.transactions.TransactionAction.Abort +import net.taler.wallet.transactions.TransactionAction.Retry +import net.taler.wallet.transactions.TransactionAction.Suspend +import net.taler.wallet.transactions.TransactionMajorState.Pending + +class TransactionLossFragment: TransactionDetailFragment() { + val scope get() = transactionManager.selectedScope + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + val t = transactionManager.selectedTransaction.observeAsState().value + val spec = scope?.let { balanceManager.getSpecForScopeInfo(it) } + + TalerSurface { + if (t is TransactionDenomLoss) { + TransitionLossComposable(t, devMode, spec) { + onTransitionButtonClicked(t, it) + } + } + } + } + } +} + +@Composable +fun TransitionLossComposable( + t: TransactionDenomLoss, + devMode: Boolean, + spec: CurrencySpecification?, + onTransition: (t: TransactionAction) -> Unit, +) { + val scrollState = rememberScrollState() + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(16.dp), + text = t.timestamp.ms.toAbsoluteTime(context).toString(), + style = MaterialTheme.typography.bodyLarge, + ) + + TransactionAmountComposable( + label = stringResource(id = R.string.loss_amount), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Negative, + ) + + TransactionInfoComposable( + label = stringResource(id = R.string.loss_reason), + info = stringResource( + when(t.lossEventType) { + DenomExpired -> R.string.loss_reason_expired + DenomVanished -> R.string.loss_reason_vanished + DenomUnoffered -> R.string.loss_reason_unoffered + } + ) + ) + + TransitionsComposable(t, devMode, onTransition) + + if (devMode && t.error != null) { + ErrorTransactionButton(error = t.error) + } + } +} + +fun previewLossTransaction(lossEventType: LossEventType) = + TransactionDenomLoss( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending), + txActions = listOf(Retry, Suspend, Abort), + amountRaw = Amount.fromString("TESTKUDOS", "0.3"), + amountEffective = Amount.fromString("TESTKUDOS", "0.3"), + error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), + lossEventType = lossEventType, + ) + +@Composable +@Preview +fun TransitionLossComposableExpiredPreview() { + val t = previewLossTransaction(DenomExpired) + Surface { + TransitionLossComposable(t, true, null) {} + } +} + +@Composable +@Preview +fun TransitionLossComposableVanishedPreview() { + val t = previewLossTransaction(DenomVanished) + Surface { + TransitionLossComposable(t, true, null) {} + } +} + +@Composable +@Preview +fun TransactionLossComposableUnofferedPreview() { + val t = previewLossTransaction(DenomUnoffered) + Surface { + TransitionLossComposable(t, true, null) {} + } +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt index 6b5a79b..d0dec41 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -16,75 +16,205 @@ package net.taler.wallet.transactions +import android.util.Log import androidx.annotation.UiThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.switchMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.taler.wallet.TAG +import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi -import java.util.HashMap +import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.transactions.TransactionAction.Delete +import net.taler.wallet.transactions.TransactionMajorState.Pending +import org.json.JSONObject import java.util.LinkedList sealed class TransactionsResult { - class Error(val msg: String) : TransactionsResult() + class Error(val error: TalerErrorInfo) : TransactionsResult() class Success(val transactions: List<Transaction>) : TransactionsResult() } class TransactionManager( private val api: WalletBackendApi, - private val scope: CoroutineScope + private val scope: CoroutineScope, ) { private val mProgress = MutableLiveData<Boolean>() val progress: LiveData<Boolean> = mProgress - var selectedCurrency: String? = null - var selectedTransaction: Transaction? = null + // FIXME if the app gets killed, this will not be restored and thus be unexpected null + // we should keep this in a savable, maybe using Hilt and SavedStateViewModel + var selectedScope: ScopeInfo? = null val searchQuery = MutableLiveData<String>(null) - private val allTransactions = HashMap<String, List<Transaction>>() - private val mTransactions = HashMap<String, MutableLiveData<TransactionsResult>>() + private val mSelectedTransaction = MutableLiveData<Transaction?>(null) + val selectedTransaction: LiveData<Transaction?> = mSelectedTransaction + private val allTransactions = HashMap<ScopeInfo, List<Transaction>>() + private val mTransactions = HashMap<ScopeInfo, MutableLiveData<TransactionsResult>>() val transactions: LiveData<TransactionsResult> @UiThread get() = searchQuery.switchMap { query -> - val currency = selectedCurrency - check(currency != null) { "Did not select currency before getting transactions" } + val scopeInfo = selectedScope + check(scopeInfo != null) { "Did not select scope before getting transactions" } loadTransactions(query) - mTransactions[currency]!! // non-null because filled in [loadTransactions] + mTransactions[scopeInfo]!! // non-null because filled in [loadTransactions] } @UiThread fun loadTransactions(searchQuery: String? = null) = scope.launch { - val currency = selectedCurrency ?: return@launch - val liveData = mTransactions.getOrPut(currency) { MutableLiveData() } - if (searchQuery == null && allTransactions.containsKey(currency)) { - liveData.value = TransactionsResult.Success(allTransactions[currency]!!) + val scopeInfo = selectedScope ?: return@launch + val liveData = mTransactions.getOrPut(scopeInfo) { MutableLiveData() } + if (searchQuery == null && allTransactions.containsKey(scopeInfo)) { + liveData.value = TransactionsResult.Success(allTransactions[scopeInfo]!!) } if (liveData.value == null) mProgress.value = true api.request("getTransactions", Transactions.serializer()) { if (searchQuery != null) put("search", searchQuery) - put("currency", currency) + put("scopeInfo", JSONObject(Json.encodeToString(scopeInfo))) }.onError { - liveData.postValue(TransactionsResult.Error(it.userFacingMsg)) + liveData.postValue(TransactionsResult.Error(it)) mProgress.postValue(false) }.onSuccess { result -> val transactions = LinkedList(result.transactions) - // TODO remove when fixed in wallet-core - val comparator = compareBy<Transaction>( - { it.pending }, - { it.timestamp.ms }, - { it.transactionId } - ) + val comparator = compareBy<Transaction> { it.txState.major == Pending } transactions.sortWith(comparator) transactions.reverse() // show latest first mProgress.value = false liveData.value = TransactionsResult.Success(transactions) - // update all transactions on UiThread if there was a currency - if (searchQuery == null) allTransactions[currency] = transactions + // update selected transaction on UiThread (if it exists) + val selected = selectedTransaction.value + if (selected != null) transactions.find { + it.transactionId == selected.transactionId + }?.let { + mSelectedTransaction.value = it + } + + // update all transactions on UiThread if there was a scope info + if (searchQuery == null) allTransactions[scopeInfo] = transactions + } + } + + /** + * Returns true if given [transactionId] was found and selected, false otherwise. + */ + @UiThread + suspend fun selectTransaction(transactionId: String): Boolean { + var transaction: Transaction? = null + api.request("getTransactionById", Transaction.serializer()) { + put("transactionId", transactionId) + }.onError { + Log.e(TAG, "Error getting transaction $it") + }.onSuccess { result -> + transaction = result + } + return if (transaction != null) { + mSelectedTransaction.value = transaction + true + } else { + false + } + } + + suspend fun getTransactionById(transactionId: String): Transaction? { + var transaction: Transaction? = null + api.request("getTransactionById", Transaction.serializer()) { + put("transactionId", transactionId) + }.onError { + Log.e(TAG, "Error getting transaction $it") + }.onSuccess { result -> + transaction = result + } + return transaction + } + + fun selectTransaction(transaction: Transaction) { + mSelectedTransaction.postValue(transaction) + } + + fun deleteTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) = + scope.launch { + api.request<Unit>("deleteTransaction") { + put("transactionId", transactionId) + }.onError { + onError(it) + }.onSuccess { + // re-load transactions as our list is stale otherwise + loadTransactions() + } + } + + fun retryTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) = + scope.launch { + api.request<Unit>("retryTransaction") { + put("transactionId", transactionId) + }.onError { + onError(it) + }.onSuccess { + loadTransactions() + } + } + + fun abortTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) = + scope.launch { + api.request<Unit>("abortTransaction") { + put("transactionId", transactionId) + }.onError { + onError(it) + }.onSuccess { + loadTransactions() + } + } + + fun failTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) = + scope.launch { + api.request<Unit>("failTransaction") { + put("transactionId", transactionId) + }.onError { + onError(it) + }.onSuccess { + loadTransactions() + } + } + + fun suspendTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) = + scope.launch { + api.request<Unit>("suspendTransaction") { + put("transactionId", transactionId) + }.onError { + onError(it) + }.onSuccess { + loadTransactions() + } + } + + fun resumeTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) = + scope.launch { + api.request<Unit>("resumeTransaction") { + put("transactionId", transactionId) + }.onError { + onError(it) + }.onSuccess { + loadTransactions() + } + } + + fun deleteTransactions(transactionIds: List<String>, onError: (it: TalerErrorInfo) -> Unit) { + allTransactions[selectedScope]?.filter { transaction -> + transaction.transactionId in transactionIds + }?.forEach { toBeDeletedTx -> + if (Delete in toBeDeletedTx.txActions) { + deleteTransaction(toBeDeletedTx.transactionId) { + onError(it) + } + } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt index 84c5c77..596a4a9 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt @@ -20,37 +20,32 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import net.taler.common.toAbsoluteTime -import net.taler.wallet.databinding.FragmentTransactionPaymentBinding +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.launchInAppBrowser +import net.taler.wallet.payment.TransactionPaymentComposable class TransactionPaymentFragment : TransactionDetailFragment() { - private lateinit var ui: FragmentTransactionPaymentBinding - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentTransactionPaymentBinding.inflate(inflater, container, false) - return ui.root + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val t = transactionManager.selectedTransaction.observeAsState().value + if (t is TransactionPayment) TransactionPaymentComposable(t, devMode, + balanceManager.getSpecForCurrency(t.amountRaw.currency), + onFulfill = { url -> + launchInAppBrowser(requireContext(), url) + }, + onTransition = { + onTransitionButtonClicked(t, it) + } + ) + } + } } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val t = transaction as TransactionPayment - ui.timeView.text = t.timestamp.ms.toAbsoluteTime(requireContext()) - - ui.amountPaidWithFeesView.text = t.amountEffective.toString() - val fee = t.amountEffective - t.amountRaw - bindOrderAndFee( - ui.orderSummaryView, - ui.orderAmountView, - ui.orderIdView, - ui.feeView, - t.info, - t.amountRaw, - fee - ) - } - } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt new file mode 100644 index 0000000..27809a7 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt @@ -0,0 +1,135 @@ +/* + * 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.transactions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.toAbsoluteTime +import net.taler.wallet.R +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.peer.TransactionPeerPullCreditComposable +import net.taler.wallet.peer.TransactionPeerPullDebitComposable +import net.taler.wallet.peer.TransactionPeerPushCreditComposable +import net.taler.wallet.peer.TransactionPeerPushDebitComposable + +class TransactionPeerFragment : TransactionDetailFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val t = transactionManager.selectedTransaction.observeAsState(null).value + if (t != null) TransactionPeerComposable(t, devMode, + balanceManager.getSpecForCurrency(t.amountRaw.currency), + ) { + onTransitionButtonClicked(t, it) + } + } + } + } +} + +@Composable +fun TransactionPeerComposable( + t: Transaction, + devMode: Boolean, + spec: CurrencySpecification?, + onTransition: (t: TransactionAction) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + horizontalAlignment = CenterHorizontally, + ) { + val context = LocalContext.current + Text( + modifier = Modifier.padding(16.dp), + text = t.timestamp.ms.toAbsoluteTime(context).toString(), + style = MaterialTheme.typography.bodyLarge, + ) + when (t) { + is TransactionPeerPullCredit -> TransactionPeerPullCreditComposable(t, spec) + is TransactionPeerPushCredit -> TransactionPeerPushCreditComposable(t, spec) + is TransactionPeerPullDebit -> TransactionPeerPullDebitComposable(t, spec) + is TransactionPeerPushDebit -> TransactionPeerPushDebitComposable(t, spec) + else -> error("unexpected transaction: ${t::class.simpleName}") + } + TransitionsComposable(t, devMode, onTransition) + if (devMode && t.error != null) { + ErrorTransactionButton(error = t.error!!) + } + } +} + +@Composable +fun TransactionAmountComposable(label: String, amount: Amount, amountType: AmountType) { + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + text = amount.toString(negative = amountType == AmountType.Negative), + fontSize = 24.sp, + color = when (amountType) { + AmountType.Positive -> colorResource(R.color.green) + AmountType.Negative -> MaterialTheme.colorScheme.error + AmountType.Neutral -> Color.Unspecified + }, + ) +} + +@Composable +fun TransactionInfoComposable(label: String, info: String) { + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + text = info, + fontSize = 24.sp, + ) +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt index 717dd33..8f474f9 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt @@ -19,38 +19,102 @@ package net.taler.wallet.transactions import android.os.Bundle import android.view.LayoutInflater import android.view.View -import android.view.View.GONE import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.Timestamp import net.taler.common.toAbsoluteTime import net.taler.wallet.R -import net.taler.wallet.cleanExchange -import net.taler.wallet.databinding.FragmentTransactionWithdrawalBinding +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.transactions.TransactionAction.Abort +import net.taler.wallet.transactions.TransactionAction.Retry +import net.taler.wallet.transactions.TransactionAction.Suspend +import net.taler.wallet.transactions.TransactionMajorState.Pending class TransactionRefreshFragment : TransactionDetailFragment() { - private lateinit var ui: FragmentTransactionWithdrawalBinding - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentTransactionWithdrawalBinding.inflate(inflater, container, false) - return ui.root + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val t = transactionManager.selectedTransaction.observeAsState().value + if (t is TransactionRefresh) TransactionRefreshComposable(t, devMode, + balanceManager.getSpecForCurrency(t.amountRaw.currency), + ) { + onTransitionButtonClicked(t, it) + } + } + } } +} - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val t = transaction as TransactionRefresh - ui.timeView.text = t.timestamp.ms.toAbsoluteTime(requireContext()) - - ui.effectiveAmountLabel.visibility = GONE - ui.effectiveAmountView.visibility = GONE - ui.confirmWithdrawalButton.visibility = GONE - ui.chosenAmountLabel.visibility = GONE - ui.chosenAmountView.visibility = GONE - val fee = t.amountEffective - ui.feeView.text = getString(R.string.amount_negative, fee.toString()) - ui. exchangeView.text = cleanExchange(t.exchangeBaseUrl) +@Composable +private fun TransactionRefreshComposable( + t: TransactionRefresh, + devMode: Boolean, + spec: CurrencySpecification?, + onTransition: (t: TransactionAction) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + horizontalAlignment = CenterHorizontally, + ) { + val context = LocalContext.current + Text( + modifier = Modifier.padding(16.dp), + text = t.timestamp.ms.toAbsoluteTime(context).toString(), + style = MaterialTheme.typography.bodyLarge, + ) + TransactionAmountComposable( + label = stringResource(id = R.string.withdraw_fees), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Negative, + ) + TransitionsComposable(t, devMode, onTransition) + if (devMode && t.error != null) { + ErrorTransactionButton(error = t.error) + } } +} +@Preview +@Composable +private fun TransactionRefreshComposablePreview() { + val t = TransactionRefresh( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending), + txActions = listOf(Retry, Suspend, Abort), + amountRaw = Amount.fromString("TESTKUDOS", "42.23"), + amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), + error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), + ) + Surface { + TransactionRefreshComposable(t, true, null) {} + } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt index 6628d6c..7992565 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt @@ -20,42 +20,27 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.content.ContextCompat.getColor -import net.taler.common.toAbsoluteTime -import net.taler.wallet.R -import net.taler.wallet.databinding.FragmentTransactionPaymentBinding +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.refund.TransactionRefundComposable class TransactionRefundFragment : TransactionDetailFragment() { - private lateinit var ui: FragmentTransactionPaymentBinding - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentTransactionPaymentBinding.inflate(inflater, container, false) - return ui.root + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val t = transactionManager.selectedTransaction.observeAsState().value + if (t is TransactionRefund) TransactionRefundComposable(t, devMode, + balanceManager.getSpecForCurrency(t.amountRaw.currency) + ) { + onTransitionButtonClicked(t, it) + } + } + } } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val t = transaction as TransactionRefund - ui.timeView.text = t.timestamp.ms.toAbsoluteTime(requireContext()) - - ui.amountPaidWithFeesLabel.text = getString(R.string.transaction_refund) - ui.amountPaidWithFeesView.setTextColor(getColor(requireContext(), R.color.green)) - ui.amountPaidWithFeesView.text = - getString(R.string.amount_positive, t.amountEffective.toString()) - val fee = t.amountRaw - t.amountEffective - bindOrderAndFee( - ui.orderSummaryView, - ui.orderAmountView, - ui.orderIdView, - ui.feeView, - t.info, - t.amountRaw, - fee - ) - } - } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt new file mode 100644 index 0000000..f89be83 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionState.kt @@ -0,0 +1,99 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.transactions + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TransactionState( + val major: TransactionMajorState, + val minor: TransactionMinorState? = null, +) { + override fun equals(other: Any?): Boolean { + return if (other is TransactionState) + // if other.minor is null, then ignore minor in comparison + major == other.major && (other.minor == null || minor == other.minor) + else false + } + + override fun hashCode(): Int { + var result = major.hashCode() + result = 31 * result + (minor?.hashCode() ?: 0) + return result + } +} + +@Serializable +enum class TransactionMajorState { + @SerialName("none") + None, + + @SerialName("pending") + Pending, + + @SerialName("done") + Done, + + @SerialName("aborting") + Aborting, + + @SerialName("aborted") + Aborted, + + @SerialName("suspended") + Suspended, + + @SerialName("dialog") + Dialog, + + @SerialName("suspended-aborting") + SuspendedAborting, + + @SerialName("failed") + Failed, + + @SerialName("deleted") + Deleted, + + @SerialName("expired") + Expired, + + @SerialName("unknown") + Unknown; +} + +@Serializable +enum class TransactionMinorState { + @SerialName("kyc") + KycRequired, + + @SerialName("exchange") + Exchange, + + @SerialName("create-purse") + CreatePurse, + + @SerialName("ready") + Ready, + + @SerialName("bank-confirm-transfer") + BankConfirmTransfer, + + @SerialName("exchange-wait-reserve") + ExchangeWaitReserve, +} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt index 8a45bec..27e59bb 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt @@ -16,52 +16,81 @@ package net.taler.wallet.transactions -import android.content.Intent -import android.content.Intent.ACTION_VIEW -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import net.taler.common.startActivitySafe -import net.taler.common.toAbsoluteTime +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import net.taler.wallet.MainViewModel import net.taler.wallet.R -import net.taler.wallet.cleanExchange -import net.taler.wallet.databinding.FragmentTransactionWithdrawalBinding +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() { +class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListener { - private lateinit var ui: FragmentTransactionWithdrawalBinding + private val model: MainViewModel by activityViewModels() + private val withdrawManager by lazy { model.withdrawManager } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentTransactionWithdrawalBinding.inflate(inflater, container, false) - return ui.root + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val t = transactionManager.selectedTransaction.observeAsState().value + if (t is TransactionWithdrawal) TransactionWithdrawalComposable( + t = t, + devMode = devMode, + spec = balanceManager.getSpecForCurrency(t.amountRaw.currency), + actionListener = this@TransactionWithdrawalFragment, + ) { + onTransitionButtonClicked(t, it) + } + } + } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val t = transaction as TransactionWithdrawal - ui.timeView.text = t.timestamp.ms.toAbsoluteTime(requireContext()) + override fun onActionButtonClicked(tx: Transaction, type: ActionListener.Type) { + when (type) { + ActionListener.Type.COMPLETE_KYC -> { + if (tx !is TransactionWithdrawal) return + tx.kycUrl?.let { + launchInAppBrowser(requireContext(), it) + } + } - ui.effectiveAmountLabel.text = getString(R.string.withdraw_total) - ui.effectiveAmountView.text = t.amountEffective.toString() - if (t.pending && t.withdrawalDetails is WithdrawalDetails.TalerBankIntegrationApi && - !t.confirmed && t.withdrawalDetails.bankConfirmationUrl != null - ) { - val i = Intent(ACTION_VIEW).apply { - data = Uri.parse(t.withdrawalDetails.bankConfirmationUrl) + ActionListener.Type.CONFIRM_WITH_BANK -> { + if (tx !is TransactionWithdrawal) return + if (tx.withdrawalDetails !is TalerBankIntegrationApi) return + tx.withdrawalDetails.bankConfirmationUrl?.let { url -> + launchInAppBrowser(requireContext(), url) + } } - ui.confirmWithdrawalButton.setOnClickListener { startActivitySafe(i) } - } else ui.confirmWithdrawalButton.visibility = View.GONE - ui.chosenAmountLabel.text = getString(R.string.amount_chosen) - ui.chosenAmountView.text = - getString(R.string.amount_positive, t.amountRaw.toString()) - val fee = t.amountRaw - t.amountEffective - ui.feeView.text = getString(R.string.amount_negative, fee.toString()) - ui.exchangeView.text = cleanExchange(t.exchangeBaseUrl) - } + ActionListener.Type.CONFIRM_MANUAL -> { + if (tx !is TransactionWithdrawal) return + if (tx.withdrawalDetails !is ManualTransfer) return + if (tx.withdrawalDetails.exchangeCreditAccountDetails.isNullOrEmpty()) return + val status = createManualTransferRequired( + transactionId = tx.transactionId, + exchangeBaseUrl = tx.exchangeBaseUrl, + amountRaw = tx.amountRaw, + amountEffective = tx.amountEffective, + withdrawalAccountList = tx.withdrawalDetails.exchangeCreditAccountDetails, + ) + 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/transactions/Transactions.kt b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt index 50181c5..7ccdbde 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt @@ -17,30 +17,92 @@ package net.taler.wallet.transactions import android.content.Context +import android.util.Log import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.annotation.StringRes +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.Transient +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonElement +import net.taler.common.Amount import net.taler.common.ContractMerchant import net.taler.common.ContractProduct -import net.taler.lib.common.Amount -import net.taler.lib.common.Timestamp +import net.taler.common.Timestamp import net.taler.wallet.R +import net.taler.wallet.TAG +import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo +import net.taler.common.CurrencySpecification import net.taler.wallet.cleanExchange +import net.taler.wallet.refund.RefundPaymentInfo +import net.taler.wallet.transactions.TransactionMajorState.None +import net.taler.wallet.transactions.TransactionMajorState.Pending import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi +import java.util.UUID @Serializable -data class Transactions(val transactions: List<Transaction>) +data class Transactions( + @Serializable(with = TransactionListSerializer::class) + val transactions: List<Transaction>, +) + +class TransactionListSerializer : KSerializer<List<Transaction>> { + private val serializer = ListSerializer(TransactionSerializer()) + override val descriptor: SerialDescriptor = serializer.descriptor + + override fun deserialize(decoder: Decoder): List<Transaction> { + return decoder.decodeSerializableValue(serializer) + } + + override fun serialize(encoder: Encoder, value: List<Transaction>) { + throw NotImplementedError() + } +} + +class TransactionSerializer : KSerializer<Transaction> { + + private val serializer = Transaction.serializer() + override val descriptor: SerialDescriptor = serializer.descriptor + private val jsonSerializer = MapSerializer(String.serializer(), JsonElement.serializer()) + + override fun deserialize(decoder: Decoder): Transaction { + return try { + decoder.decodeSerializableValue(serializer) + } catch (e: SerializationException) { + Log.e(TAG, "Error deserializing transaction.", e) + DummyTransaction( + transactionId = UUID.randomUUID().toString(), + timestamp = Timestamp.now(), + error = TalerErrorInfo( + code = TalerErrorCode.UNKNOWN, + message = e.message, + extra = decoder.decodeSerializableValue(jsonSerializer) + ), + ) + } + } + + override fun serialize(encoder: Encoder, value: Transaction) { + throw NotImplementedError() + } +} @Serializable sealed class Transaction { abstract val transactionId: String abstract val timestamp: Timestamp - abstract val pending: Boolean + abstract val txState: TransactionState + abstract val txActions: List<TransactionAction> abstract val error: TalerErrorInfo? abstract val amountRaw: Amount abstract val amountEffective: Amount @@ -59,6 +121,28 @@ sealed class Transaction { abstract val generalTitleRes: Int } +@Serializable +enum class TransactionAction { + // Common States + @SerialName("delete") + Delete, + + @SerialName("suspend") + Suspend, + + @SerialName("resume") + Resume, + + @SerialName("abort") + Abort, + + @SerialName("fail") + Fail, + + @SerialName("retry") + Retry, +} + sealed class AmountType { object Positive : AmountType() object Negative : AmountType() @@ -70,12 +154,14 @@ sealed class AmountType { class TransactionWithdrawal( override val transactionId: String, override val timestamp: Timestamp, - override val pending: Boolean, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, + val kycUrl: String? = null, val exchangeBaseUrl: String, val withdrawalDetails: WithdrawalDetails, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, - override val amountEffective: Amount + override val amountEffective: Amount, ) : Transaction() { override val icon = R.drawable.transaction_withdrawal @@ -86,7 +172,7 @@ class TransactionWithdrawal( override fun getTitle(context: Context) = cleanExchange(exchangeBaseUrl) override val generalTitleRes = R.string.withdraw_title val confirmed: Boolean - get() = !pending && ( + get() = txState.major != Pending && ( (withdrawalDetails is TalerBankIntegrationApi && withdrawalDetails.confirmed) || withdrawalDetails is ManualTransfer ) @@ -97,12 +183,7 @@ sealed class WithdrawalDetails { @Serializable @SerialName("manual-transfer") class ManualTransfer( - /** - * Payto URIs that the exchange supports. - * - * Already contains the amount and message. - */ - val exchangePaytoUris: List<String> + val exchangeCreditAccountDetails: List<WithdrawalExchangeAccountDetails>? = null, ) : WithdrawalDetails() @Serializable @@ -124,16 +205,108 @@ sealed class WithdrawalDetails { } @Serializable +data class WithdrawalExchangeAccountDetails ( + /** + * Payto URI to credit the exchange. + * + * Depending on whether the (manual!) withdrawal is accepted or just + * being checked, this already includes the subject with the + * reserve public key. + */ + val paytoUri: String, + + /** + * Status that indicates whether the account can be used + * by the user to send funds for a withdrawal. + * + * ok: account should be shown to the user + * error: account should not be shown to the user, UIs might render the error (in conversionError), + * especially in dev mode. + */ + val status: Status, + + /** + * Transfer amount. Might be in a different currency than the requested + * amount for withdrawal. + * + * Redundant with the amount in paytoUri, just included to avoid parsing. + */ + val transferAmount: Amount? = null, + + /** + * Currency specification for the external currency. + * + * Only included if this account requires a currency conversion. + */ + val currencySpecification: CurrencySpecification? = null, + + /** + * Further restrictions for sending money to the + * exchange. + */ + val creditRestrictions: List<AccountRestriction>? = null, + + /** + * Label given to the account or the account's bank by the exchange. + */ + val bankLabel: String? = null, + + val priority: Int? = null, +) { + @Serializable + enum class Status { + @SerialName("ok") + Ok, + + @SerialName("error") + Error; + } +} + +@Serializable +sealed class AccountRestriction { + @Serializable + @SerialName("deny") + data object DenyAllAccount: AccountRestriction() + + @Serializable + @SerialName("regex") + data class RegexAccount( + // Regular expression that the payto://-URI of the + // partner account must follow. The regular expression + // should follow posix-egrep, but without support for character + // classes, GNU extensions, back-references or intervals. See + // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html + // for a description of the posix-egrep syntax. Applications + // may support regexes with additional features, but exchanges + // must not use such regexes. + @SerialName("payto_regex") + val paytoRegex: String, + + // Hint for a human to understand the restriction + // (that is hopefully easier to comprehend than the regex itself). + @SerialName("human_hint") + val humanHint: String, + + // Map from IETF BCP 47 language tags to localized + // human hints. + @SerialName("human_hint_i18n") + val humanHintI18n: Map<String, String>? = null, + ): AccountRestriction() +} + +@Serializable @SerialName("payment") class TransactionPayment( override val transactionId: String, override val timestamp: Timestamp, - override val pending: Boolean, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, val info: TransactionInfo, - val status: PaymentStatus, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, - override val amountEffective: Amount + override val amountEffective: Amount, + val posConfirmation: String? = null, ) : Transaction() { override val icon = R.drawable.ic_cash_usd_outline override val detailPageNav = R.id.action_nav_transactions_detail_payment @@ -151,7 +324,7 @@ class TransactionInfo( val summary: String, @SerialName("summary_i18n") val summaryI18n: Map<String, String>? = null, - val products: List<ContractProduct>, + val products: List<ContractProduct> = emptyList(), val fulfillmentUrl: String? = null, /** * Message shown to the user after the payment is complete. @@ -164,35 +337,17 @@ class TransactionInfo( ) @Serializable -enum class PaymentStatus { - @SerialName("aborted") - Aborted, - - @SerialName("failed") - Failed, - - @SerialName("paid") - Paid, - - @SerialName("accepted") - Accepted -} - -@Serializable @SerialName("refund") class TransactionRefund( override val transactionId: String, override val timestamp: Timestamp, - override val pending: Boolean, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, val refundedTransactionId: String, - val info: TransactionInfo, - /** - * Part of the refund that couldn't be applied because the refund permissions were expired - */ - val amountInvalid: Amount? = null, + val paymentInfo: RefundPaymentInfo? = null, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, - override val amountEffective: Amount + override val amountEffective: Amount, ) : Transaction() { override val icon = R.drawable.transaction_refund override val detailPageNav = R.id.action_nav_transactions_detail_refund @@ -200,56 +355,240 @@ class TransactionRefund( @Transient override val amountType = AmountType.Positive override fun getTitle(context: Context): String { - return context.getString(R.string.transaction_refund_from, info.merchant.name) + val merchantName = paymentInfo?.merchant?.name ?: "null" + return context.getString(R.string.transaction_refund_from, merchantName) } override val generalTitleRes = R.string.refund_title } @Serializable -@SerialName("tip") -class TransactionTip( +@SerialName("refresh") +class TransactionRefresh( + override val transactionId: String, + override val timestamp: Timestamp, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, + override val error: TalerErrorInfo? = null, + override val amountRaw: Amount, + override val amountEffective: Amount, +) : Transaction() { + override val icon = R.drawable.transaction_refresh + override val detailPageNav = R.id.action_nav_transactions_detail_refresh + + @Transient + override val amountType = AmountType.Negative + override fun getTitle(context: Context): String { + return context.getString(R.string.transaction_refresh) + } + + override val generalTitleRes = R.string.transaction_refresh +} + +@Serializable +@SerialName("deposit") +class TransactionDeposit( + override val transactionId: String, + override val timestamp: Timestamp, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, + override val error: TalerErrorInfo? = null, + override val amountRaw: Amount, + override val amountEffective: Amount, + val targetPaytoUri: String, + val depositGroupId: String, +) : Transaction() { + override val icon = R.drawable.ic_cash_usd_outline + override val detailPageNav = R.id.action_nav_transactions_detail_deposit + + @Transient + override val amountType = AmountType.Negative + override fun getTitle(context: Context): String { + return context.getString(R.string.transaction_deposit) + } + + override val generalTitleRes = R.string.transaction_deposit +} + +@Serializable +data class PeerInfoShort( + val expiration: Timestamp? = null, + val summary: String? = null, +) + +/** + * Debit because we paid someone's invoice. + */ +@Serializable +@SerialName("peer-pull-debit") +class TransactionPeerPullDebit( override val transactionId: String, override val timestamp: Timestamp, - override val pending: Boolean, - // TODO status: TipStatus, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, val exchangeBaseUrl: String, - val merchant: ContractMerchant, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, - override val amountEffective: Amount + override val amountEffective: Amount, + val info: PeerInfoShort, ) : Transaction() { - override val icon = R.drawable.transaction_tip_accepted // TODO different when declined - override val detailPageNav = 0 + override val icon = R.drawable.ic_cash_usd_outline + override val detailPageNav = R.id.nav_transactions_detail_peer @Transient - override val amountType = AmountType.Positive + override val amountType = AmountType.Negative override fun getTitle(context: Context): String { - return context.getString(R.string.transaction_tip_from, merchant.name) + return context.getString(R.string.transaction_peer_pull_debit) } - override val generalTitleRes = R.string.tip_title + override val generalTitleRes = R.string.transaction_peer_pull_debit } +/** + * Credit because someone paid for an invoice we created. + */ @Serializable -@SerialName("refresh") -class TransactionRefresh( +@SerialName("peer-pull-credit") +class TransactionPeerPullCredit( override val transactionId: String, override val timestamp: Timestamp, - override val pending: Boolean, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, val exchangeBaseUrl: String, override val error: TalerErrorInfo? = null, override val amountRaw: Amount, - override val amountEffective: Amount + override val amountEffective: Amount, + val info: PeerInfoShort, + val talerUri: String, + // val completed: Boolean, maybe ) : Transaction() { - override val icon = R.drawable.transaction_refresh - override val detailPageNav = R.id.action_nav_transactions_detail_refresh + override val icon = R.drawable.transaction_withdrawal + override val detailPageNav = R.id.nav_transactions_detail_peer + + override val amountType get() = AmountType.Positive + override fun getTitle(context: Context): String { + return context.getString(R.string.transaction_peer_pull_credit) + } + + override val generalTitleRes = R.string.transaction_peer_pull_credit +} + +/** + * Debit because we sent money to someone. + */ +@Serializable +@SerialName("peer-push-debit") +class TransactionPeerPushDebit( + override val transactionId: String, + override val timestamp: Timestamp, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, + val exchangeBaseUrl: String, + override val error: TalerErrorInfo? = null, + override val amountRaw: Amount, + override val amountEffective: Amount, + val info: PeerInfoShort, + val talerUri: String? = null, + // val completed: Boolean, definitely +) : Transaction() { + override val icon = R.drawable.ic_cash_usd_outline + override val detailPageNav = R.id.nav_transactions_detail_peer @Transient override val amountType = AmountType.Negative override fun getTitle(context: Context): String { - return context.getString(R.string.transaction_refresh) + return context.getString(R.string.transaction_peer_push_debit) } - override val generalTitleRes = R.string.transaction_refresh + override val generalTitleRes = R.string.payment_title +} + +/** + * We received money via a peer payment. + */ +@Serializable +@SerialName("peer-push-credit") +class TransactionPeerPushCredit( + override val transactionId: String, + override val timestamp: Timestamp, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, + val exchangeBaseUrl: String, + override val error: TalerErrorInfo? = null, + override val amountRaw: Amount, + override val amountEffective: Amount, + val info: PeerInfoShort, +) : Transaction() { + override val icon = R.drawable.transaction_withdrawal + override val detailPageNav = R.id.nav_transactions_detail_peer + + @Transient + override val amountType = AmountType.Positive + override fun getTitle(context: Context): String { + return context.getString(R.string.transaction_peer_push_credit) + } + + override val generalTitleRes = R.string.transaction_peer_push_credit +} + +/** + * A transaction to indicate financial loss due to denominations + * that became unusable for deposits. + */ +@Serializable +@SerialName("denom-loss") +class TransactionDenomLoss( + override val transactionId: String, + override val timestamp: Timestamp, + override val txState: TransactionState, + override val txActions: List<TransactionAction>, + override val error: TalerErrorInfo? = null, + override val amountRaw: Amount, + override val amountEffective: Amount, + val lossEventType: LossEventType, +): Transaction() { + override val icon: Int = R.drawable.transaction_loss + override val detailPageNav = R.id.nav_transactions_detail_loss + + @Transient + override val amountType: AmountType = AmountType.Negative + + override fun getTitle(context: Context): String { + return context.getString(R.string.transaction_denom_loss) + } + + override val generalTitleRes: Int = R.string.transaction_denom_loss +} + +@Serializable +enum class LossEventType { + @SerialName("denom-expired") + DenomExpired, + + @SerialName("denom-vanished") + DenomVanished, + + @SerialName("denom-unoffered") + DenomUnoffered +} + +/** + * This represents a transaction that we can not parse for some reason. + */ +class DummyTransaction( + override val transactionId: String, + override val timestamp: Timestamp, + override val error: TalerErrorInfo, +) : Transaction() { + override val txState: TransactionState = TransactionState(None) + override val txActions: List<TransactionAction> = emptyList() + override val amountRaw: Amount = Amount.zero("TESTKUDOS") + override val amountEffective: Amount = Amount.zero("TESTKUDOS") + override val icon: Int = R.drawable.ic_bug_report + override val detailPageNav: Int = R.id.nav_transactions_detail_dummy + override val amountType: AmountType = AmountType.Neutral + override val generalTitleRes: Int = R.string.transaction_dummy_title + override fun getTitle(context: Context): String { + return context.getString(R.string.transaction_dummy_title) + } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt index 90510e6..5243427 100644 --- a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt @@ -17,15 +17,15 @@ package net.taler.wallet.transactions import android.os.Bundle +import android.util.Log import android.view.ActionMode import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View +import android.view.View.INVISIBLE import android.view.ViewGroup -import android.widget.Toast -import android.widget.Toast.LENGTH_LONG import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView.OnQueryTextListener import androidx.fragment.app.Fragment @@ -36,11 +36,16 @@ import androidx.recyclerview.selection.SelectionTracker import androidx.recyclerview.selection.StorageStrategy import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL +import com.google.android.material.dialog.MaterialAlertDialogBuilder import net.taler.common.fadeIn import net.taler.common.fadeOut +import net.taler.common.showError import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.TAG +import net.taler.wallet.balances.BalanceState.Success import net.taler.wallet.databinding.FragmentTransactionsBinding +import net.taler.wallet.showError interface OnTransactionClickListener { fun onTransactionClicked(transaction: Transaction) @@ -50,10 +55,11 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. private val model: MainViewModel by activityViewModels() private val transactionManager by lazy { model.transactionManager } + private val balanceManager by lazy { model.balanceManager } private lateinit var ui: FragmentTransactionsBinding private val transactionAdapter by lazy { TransactionAdapter(this) } - private val currency by lazy { transactionManager.selectedCurrency!! } + private val scopeInfo by lazy { transactionManager.selectedScope!! } private var tracker: SelectionTracker<String>? = null private var actionMode: ActionMode? = null @@ -64,8 +70,8 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { + savedInstanceState: Bundle?, + ): View { ui = FragmentTransactionsBinding.inflate(inflater, container, false) return ui.root } @@ -102,22 +108,36 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. } }) - transactionManager.progress.observe(viewLifecycleOwner, { show -> - if (show) ui.progressBar.fadeIn() else ui.progressBar.fadeOut() - }) - transactionManager.transactions.observe(viewLifecycleOwner, { result -> - onTransactionsResult(result) - }) - } + balanceManager.state.observe(viewLifecycleOwner) { state -> + if (state !is Success) return@observe + val balances = state.balances + // hide extra fab when in single currency mode (uses MainFragment's FAB) + if (balances.size == 1) ui.mainFab.visibility = INVISIBLE - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - model.balances.observe(viewLifecycleOwner, { balances -> - balances.find { it.currency == currency }?.available?.let { amount -> - requireActivity().title = - getString(R.string.transactions_detail_title_balance, amount) + balances.find { it.scopeInfo == scopeInfo }?.let { balance -> + ui.amount.text = balance.available.toString(showSymbol = false) + transactionAdapter.setCurrencySpec(balance.available.spec) } - }) + } + transactionManager.progress.observe(viewLifecycleOwner) { show -> + if (show) ui.progressBar.fadeIn() else ui.progressBar.fadeOut() + } + transactionManager.transactions.observe(viewLifecycleOwner) { result -> + onTransactionsResult(result) + } + ui.sendButton.setOnClickListener { + findNavController().navigate(R.id.sendFunds) + } + ui.receiveButton.setOnClickListener { + findNavController().navigate(R.id.action_global_receiveFunds) + } + ui.mainFab.setOnClickListener { + model.scanCode() + } + ui.mainFab.setOnLongClickListener { + findNavController().navigate(R.id.action_nav_transactions_to_nav_uri_input) + true + } } override fun onSaveInstanceState(outState: Bundle) { @@ -125,11 +145,17 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. tracker?.onSaveInstanceState(outState) } + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.transactions, menu) setupSearch(menu.findItem(R.id.action_search)) } + override fun onStart() { + super.onStart() + requireActivity().title = getString(R.string.transactions_detail_title_currency, scopeInfo.currency) + } + private fun setupSearch(item: MenuItem) { item.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem) = true @@ -145,7 +171,7 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. // workaround to avoid issues with some emulators and keyboard devices // firing twice if a keyboard enter is used // see https://code.google.com/p/android/issues/detail?id=24599 - item.actionView.clearFocus() + searchView.clearFocus() onSearch(query) return true } @@ -155,7 +181,7 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. override fun onTransactionClicked(transaction: Transaction) { if (actionMode != null) return // don't react on clicks while in action mode if (transaction.detailPageNav != 0) { - transactionManager.selectedTransaction = transaction + transactionManager.selectTransaction(transaction) findNavController().navigate(transaction.detailPageNav) } } @@ -163,9 +189,10 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. private fun onTransactionsResult(result: TransactionsResult) = when (result) { is TransactionsResult.Error -> { ui.list.fadeOut() - ui.emptyState.text = getString(R.string.transactions_error, result.msg) + ui.emptyState.text = getString(R.string.transactions_error, result.error.userFacingMsg) ui.emptyState.fadeIn() } + is TransactionsResult.Success -> { if (result.transactions.isEmpty()) { val isSearch = transactionManager.searchQuery.value != null @@ -203,10 +230,32 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { when (item.itemId) { R.id.transaction_delete -> { - val s = "Not yet implemented. Pester Florian! ;)" - Toast.makeText(requireContext(), s, LENGTH_LONG).show() + tracker?.selection?.toList()?.let { transactionIds -> + MaterialAlertDialogBuilder( + requireContext(), + R.style.MaterialAlertDialog_Material3, + ) + .setTitle(R.string.transactions_delete) + .setMessage(R.string.transactions_delete_selected_dialog_message) + .setNeutralButton(R.string.cancel) { dialog, _ -> + dialog.cancel() + } + .setNegativeButton(R.string.transactions_delete) { dialog, _ -> + transactionManager.deleteTransactions(transactionIds) { + Log.e(TAG, "Error deleteTransaction $it") + if (model.devMode.value == true) { + showError(it) + } else { + showError(it.userFacingMsg) + } + } + dialog.dismiss() + } + .show() + } mode.finish() } + R.id.transaction_select_all -> transactionAdapter.selectAll() } return true diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransitionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransitionsComposable.kt new file mode 100644 index 0000000..424cc2a --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/transactions/TransitionsComposable.kt @@ -0,0 +1,113 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.transactions + +import androidx.compose.foundation.layout.Arrangement.Center +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import net.taler.wallet.R +import net.taler.wallet.transactions.TransactionAction.* + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TransitionsComposable( + t: Transaction, + devMode: Boolean, + onTransition: (t: TransactionAction) -> Unit, +) { + FlowRow(horizontalArrangement = Center) { + t.txActions.forEach { + if (it in arrayOf(Resume, Suspend)) { + if (devMode) TransitionComposable(it, onTransition) + } else { + TransitionComposable(it, onTransition) + } + } + } +} + +@Composable +fun TransitionComposable(t: TransactionAction, onClick: (t: TransactionAction) -> Unit) { + Button( + modifier = Modifier.padding(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = when (t) { + Delete -> MaterialTheme.colorScheme.error + Retry -> MaterialTheme.colorScheme.primary + Abort -> MaterialTheme.colorScheme.error + Fail -> MaterialTheme.colorScheme.error + Resume -> MaterialTheme.colorScheme.primary + Suspend -> MaterialTheme.colorScheme.primary + } + ), + onClick = { onClick(t) }, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = when (t) { + Delete -> painterResource(id = R.drawable.ic_delete) + Retry -> painterResource(id = R.drawable.ic_retry) + Abort -> painterResource(id = R.drawable.ic_cancel) + Fail -> painterResource(id = R.drawable.ic_fail) + Resume -> painterResource(id = R.drawable.ic_resume) + Suspend -> painterResource(id = R.drawable.ic_suspend) + }, + contentDescription = null, + tint = when (t) { + Delete -> MaterialTheme.colorScheme.onError + Retry -> MaterialTheme.colorScheme.onPrimary + Abort -> MaterialTheme.colorScheme.onError + Fail -> MaterialTheme.colorScheme.onError + Resume -> MaterialTheme.colorScheme.onPrimary + Suspend -> MaterialTheme.colorScheme.onPrimary + }, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = when (t) { + Delete -> stringResource(R.string.transactions_delete) + Retry -> stringResource(R.string.transactions_retry) + Abort -> stringResource(R.string.transactions_abort) + Fail -> stringResource(R.string.transactions_fail) + Resume -> stringResource(R.string.transactions_resume) + Suspend -> stringResource(R.string.transactions_suspend) + }, + color = when (t) { + Delete -> MaterialTheme.colorScheme.onError + Retry -> MaterialTheme.colorScheme.onPrimary + Abort -> MaterialTheme.colorScheme.onError + Fail -> MaterialTheme.colorScheme.onError + Resume -> MaterialTheme.colorScheme.onPrimary + Suspend -> MaterialTheme.colorScheme.onPrimary + }, + ) + } + } +} |