taler-android

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

commit b30fc9a3404b9c0f9a971432be773fe5aebaa98f
parent f19571c9cbacc38789490d3c33c77ea2d26a595d
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 24 Oct 2024 14:31:14 +0200

[wallet] Rewrite balances and transactions in Compose

Diffstat:
Mwallet/src/main/java/net/taler/wallet/MainFragment.kt | 63+++++++++++++++++++++++----------------------------------------
Dwallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt | 130-------------------------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/balances/Balances.kt | 1-
Awallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dwallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt | 109-------------------------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt | 15++++++++++-----
Awallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt | 361+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt | 5+++++
8 files changed, 642 insertions(+), 285 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt @@ -16,6 +16,7 @@ package net.taler.wallet +import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -69,16 +70,13 @@ import androidx.fragment.compose.FragmentState import androidx.fragment.compose.rememberFragmentState import androidx.navigation.fragment.findNavController import net.taler.wallet.balances.BalanceState -import net.taler.wallet.balances.BalancesFragment -import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.balances.BalancesComposable import net.taler.wallet.compose.DemandAttention import net.taler.wallet.compose.GridMenu import net.taler.wallet.compose.GridMenuItem -import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.settings.SettingsFragment -import net.taler.wallet.transactions.TransactionsFragment -import net.taler.wallet.withdraw.WithdrawalError +import net.taler.wallet.transactions.TransactionsResult class MainFragment: Fragment() { @@ -98,8 +96,6 @@ class MainFragment: Fragment() { var showSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState() - val balancesFragmentState = rememberFragmentState() - val transactionsFragmentState = rememberFragmentState() val settingsFragmentState = rememberFragmentState() Scaffold( @@ -149,18 +145,30 @@ class MainFragment: Fragment() { } ) { innerPadding -> val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) + val txResult by model.transactionManager.transactions.observeAsState(TransactionsResult.None) val selectedScope by model.transactionManager.selectedScope.observeAsState() - Box( - Modifier - .fillMaxSize() - .padding(innerPadding), - ) { + val selectedSpec = remember(selectedScope) { selectedScope?.let { model.balanceManager.getSpecForScopeInfo(it) } } + Box(Modifier.padding(innerPadding).fillMaxSize()) { when (selectedTab) { - Tab.BALANCES -> BalancesView( + Tab.BALANCES -> BalancesComposable( state = balanceState, + txResult = txResult, selectedScope = selectedScope, - balancesFragmentState = balancesFragmentState, - transactionsFragmentState = transactionsFragmentState, + selectedCurrencySpec = selectedSpec, + onBalanceClicked = { + model.showTransactions(it.scopeInfo) + }, + onTransactionClicked = { tx -> + if (tx.detailPageNav != 0) { + model.transactionManager.selectTransaction(tx) + findNavController().navigate(tx.detailPageNav) + } + }, + onShowBalancesClicked = { + if (model.transactionManager.selectedScope.value != null) { + model.transactionManager.selectedScope.value = null + } + }, ) Tab.SETTINGS -> SettingsView( settingsFragmentState = settingsFragmentState, @@ -216,31 +224,6 @@ class MainFragment: Fragment() { } @Composable -fun BalancesView( - selectedScope: ScopeInfo? = null, - state: BalanceState, - balancesFragmentState: FragmentState, - transactionsFragmentState: FragmentState, -) { - when (state) { - is BalanceState.None -> {} - is BalanceState.Loading -> LoadingScreen() - is BalanceState.Error -> WithdrawalError(state.error) - is BalanceState.Success -> { - if (selectedScope == null) AndroidFragment( - BalancesFragment::class.java, - modifier = Modifier.fillMaxSize(), - fragmentState = balancesFragmentState - ) else AndroidFragment( - TransactionsFragment::class.java, - modifier = Modifier.fillMaxSize(), - fragmentState = transactionsFragmentState, - ) - } - } -} - -@Composable fun SettingsView( settingsFragmentState: FragmentState, ) { diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceAdapter.kt @@ -1,129 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 Taler Systems S.A. - * - * GNU Taler is free software; you can redistribute it and/or modify it under the - * terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3, or (at your option) any later version. - * - * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - * A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -package net.taler.wallet.balances - -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.Adapter -import net.taler.wallet.R -import net.taler.wallet.balances.BalanceAdapter.BalanceViewHolder -import net.taler.wallet.balances.ScopeInfo.Auditor -import net.taler.wallet.balances.ScopeInfo.Exchange -import net.taler.wallet.balances.ScopeInfo.Global -import net.taler.wallet.cleanExchange - -class BalanceAdapter(private val listener: BalanceClickListener) : Adapter<BalanceViewHolder>() { - - private var items = emptyList<BalanceItem>() - - init { - setHasStableIds(false) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BalanceViewHolder { - val v = - LayoutInflater.from(parent.context).inflate(R.layout.list_item_balance, parent, false) - return BalanceViewHolder(v) - } - - override fun getItemCount() = items.size - - override fun onBindViewHolder(holder: BalanceViewHolder, position: Int) { - val item = items[position] - holder.bind(item) - } - - fun update(newItems: List<BalanceItem>) { - val oldItems = this.items - - val diffCallback = BalanceDiffCallback(oldItems, newItems) - val diffResult = DiffUtil.calculateDiff(diffCallback) - diffResult.dispatchUpdatesTo(this) - - this.items = newItems - } - - inner class BalanceViewHolder(private val v: View) : RecyclerView.ViewHolder(v) { - private val amountView: TextView = v.findViewById(R.id.balanceAmountView) - private val scopeView: TextView = v.findViewById(R.id.scopeView) - private val balanceInboundAmount: TextView = v.findViewById(R.id.balanceInboundAmount) - private val balanceOutboundAmount: TextView = v.findViewById(R.id.balanceOutboundAmount) - - fun bind(item: BalanceItem) { - v.setOnClickListener { listener.onBalanceClick(item.scopeInfo) } - amountView.text = item.available.toString() - - val amountIncoming = item.pendingIncoming - if (amountIncoming.isZero()) { - balanceInboundAmount.visibility = GONE - } else { - balanceInboundAmount.visibility = VISIBLE - balanceInboundAmount.text = v.context.getString(R.string.balances_inbound_amount, amountIncoming.toString(showSymbol = false)) - } - - val amountOutgoing = item.pendingOutgoing - if (amountOutgoing.isZero()) { - balanceOutboundAmount.visibility = GONE - } else { - balanceOutboundAmount.visibility = VISIBLE - balanceOutboundAmount.text = v.context.getString(R.string.balances_outbound_amount, amountOutgoing.toString(showSymbol = false)) - } - - val scopeInfo = item.scopeInfo - scopeView.visibility = when (scopeInfo) { - is Global -> GONE - is Exchange -> { - scopeView.text = v.context.getString(R.string.balance_scope_exchange, cleanExchange(scopeInfo.url)) - VISIBLE - } - is Auditor -> { - scopeView.text = v.context.getString(R.string.balance_scope_auditor, cleanExchange(scopeInfo.url)) - VISIBLE - } - } - } - } -} - -internal class BalanceDiffCallback( - private val oldList: List<BalanceItem>, - private val newList: List<BalanceItem>, -): DiffUtil.Callback() { - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val old = oldList[oldItemPosition] - val new = newList[newItemPosition] - - return old.scopeInfo == new.scopeInfo - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val old = oldList[oldItemPosition] - val new = newList[newItemPosition] - - return old == new - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/balances/Balances.kt b/wallet/src/main/java/net/taler/wallet/balances/Balances.kt @@ -28,7 +28,6 @@ data class BalanceItem( val pendingOutgoing: Amount, ) { val currency: String get() = available.currency - val hasPending: Boolean get() = !pendingIncoming.isZero() || !pendingOutgoing.isZero() } @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt @@ -0,0 +1,242 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.balances + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.wallet.R +import net.taler.wallet.balances.ScopeInfo.Auditor +import net.taler.wallet.balances.ScopeInfo.Exchange +import net.taler.wallet.balances.ScopeInfo.Global +import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.transactions.Transaction +import net.taler.wallet.transactions.TransactionsComposable +import net.taler.wallet.transactions.TransactionsResult +import net.taler.wallet.withdraw.WithdrawalError + +@Composable +fun BalancesComposable( + state: BalanceState, + txResult: TransactionsResult, + selectedScope: ScopeInfo?, + selectedCurrencySpec: CurrencySpecification?, + onBalanceClicked: (balance: BalanceItem) -> Unit, + onTransactionClicked: (tx: Transaction) -> Unit, + onShowBalancesClicked: () -> Unit, +) { + when (state) { + is BalanceState.None -> {} + is BalanceState.Loading -> LoadingScreen() + is BalanceState.Error -> WithdrawalError(state.error) + is BalanceState.Success -> if (selectedScope == null) { + val balances = remember(state.balances) { + state.balances.distinctBy { it.scopeInfo } + } + + if (state.balances.isNotEmpty()) { + LazyColumn(Modifier.fillMaxSize()) { + items(balances, key = { it.scopeInfo.hashCode() }) { balance -> + BalanceRow(balance) { + onBalanceClicked(balance) + } + } + } + } else { + EmptyBalancesComposable() + } + } else { + val balance = remember(state.balances, selectedScope) { + state.balances.find { it.scopeInfo == selectedScope } + } + + balance?.let { + TransactionsComposable( + balance = it, + currencySpec = selectedCurrencySpec, + txResult = txResult, + onTransactionClick = onTransactionClicked, + onShowBalancesClicked = onShowBalancesClicked, + ) + } ?: error("no balance matching scopeInfo") + } + } +} + +@Composable +fun BalanceRow( + balance: BalanceItem, + onClick: () -> Unit, +) { + OutlinedCard( + modifier = Modifier + .padding( + horizontal = 9.dp, + vertical = 6.dp, + ).clickable { onClick() }, + ) { + ListItem( + modifier = Modifier + .animateContentSize() + .padding(6.dp), + headlineContent = { + Text( + balance.available.toString(), + style = MaterialTheme.typography.displaySmall, + ) + }, + overlineContent = { + ProvideTextStyle(MaterialTheme.typography.labelLarge) { + when (balance.scopeInfo) { + is Exchange -> Text( + stringResource( + R.string.balance_scope_exchange, + cleanExchange(balance.scopeInfo.url) + ), + ) + + is Auditor -> Text( + stringResource( + R.string.balance_scope_auditor, + cleanExchange(balance.scopeInfo.url) + ), + ) + + else -> {} + } + } + }, + supportingContent = { + Column { + ProvideTextStyle(MaterialTheme.typography.bodyLarge) { + AnimatedVisibility(!balance.pendingIncoming.isZero()) { + Text( + stringResource( + R.string.balances_inbound_amount, + balance.pendingIncoming.toString(showSymbol = false), + ), + color = colorResource(R.color.green), + ) + } + + AnimatedVisibility(!balance.pendingOutgoing.isZero()) { + Text( + stringResource( + R.string.balances_outbound_amount, + balance.pendingOutgoing.toString(showSymbol = false) + ), + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + ) + } +} + +@Composable +fun EmptyBalancesComposable() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + // TODO: render hyperlink! + Text( + stringResource(R.string.balances_empty_state), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Preview +@Composable +fun BalancesComposablePreview() { + val balances = listOf( + BalanceItem( + scopeInfo = Global("CHF"), + available = Amount.fromJSONString("CHF:10.20"), + pendingIncoming = Amount.fromJSONString("CHF:1.20"), + pendingOutgoing = Amount.fromJSONString("CHF:0.40"), + ), + BalanceItem( + scopeInfo = Exchange("KUDOS", "https://exchange.demo.taler.net"), + available = Amount.fromJSONString("KUDOS:1407.37"), + pendingIncoming = Amount.fromJSONString("KUDOS:0"), + pendingOutgoing = Amount.fromJSONString("KUDOS:2.15"), + ), + BalanceItem( + scopeInfo = Auditor("MXN", "https://auditor.taler.banxico.org.mx"), + available = Amount.fromJSONString("MXN:5.50"), + pendingIncoming = Amount.fromJSONString("MXN:1.40"), + pendingOutgoing = Amount.fromJSONString("MXN:0"), + ), + ) + + TalerSurface { + BalancesComposable( + state = BalanceState.Success(balances), + txResult = TransactionsResult.Success(listOf()), + selectedScope = null, + selectedCurrencySpec = null, + onBalanceClicked = {}, + onTransactionClicked = {}, + onShowBalancesClicked = {}, + ) + } +} + +@Preview +@Composable +fun BalancesComposableEmptyPreview() { + TalerSurface { + BalancesComposable( + state = BalanceState.Success(listOf()), + txResult = TransactionsResult.Success(listOf()), + selectedScope = null, + selectedCurrencySpec = null, + onBalanceClicked = {}, + onTransactionClicked = {}, + onShowBalancesClicked = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesFragment.kt @@ -1,109 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 Taler Systems S.A. - * - * GNU Taler is free software; you can redistribute it and/or modify it under the - * terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3, or (at your option) any later version. - * - * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - * A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -package net.taler.wallet.balances - -import android.os.Bundle -import android.transition.TransitionManager.beginDelayedTransition -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.View.INVISIBLE -import android.view.View.VISIBLE -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL -import net.taler.common.fadeIn -import net.taler.common.showError -import net.taler.wallet.MainViewModel -import net.taler.wallet.R -import net.taler.wallet.balances.BalanceState.Error -import net.taler.wallet.balances.BalanceState.Loading -import net.taler.wallet.balances.BalanceState.None -import net.taler.wallet.balances.BalanceState.Success -import net.taler.wallet.databinding.FragmentBalancesBinding -import net.taler.wallet.showError - -interface BalanceClickListener { - fun onBalanceClick(scopeInfo: ScopeInfo) -} - -class BalancesFragment : Fragment(), - BalanceClickListener { - - private val model: MainViewModel by activityViewModels() - - private lateinit var ui: FragmentBalancesBinding - private val balancesAdapter = BalanceAdapter(this) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - ui = FragmentBalancesBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - ui.mainList.apply { - adapter = balancesAdapter - addItemDecoration(DividerItemDecoration(context, VERTICAL)) - } - - model.balanceManager.state.observe(viewLifecycleOwner) { - onBalancesChanged(it) - } - } - - override fun onStart() { - super.onStart() - requireActivity().title = getString(R.string.balances_title) - } - - private fun onBalancesChanged(state: BalanceState) { - model.showProgressBar.value = false - when (state) { - is None -> {} - is Loading -> { - model.showProgressBar.value = true - } - is Success -> { - beginDelayedTransition(view as ViewGroup) - if (state.balances.isEmpty()) { - ui.mainEmptyState.visibility = VISIBLE - ui.mainList.visibility = GONE - } else { - balancesAdapter.update(state.balances) - ui.mainEmptyState.visibility = INVISIBLE - ui.mainList.fadeIn() - } - } - is Error -> if (model.devMode.value == true) { - showError(state.error) - } else { - showError(state.error.userFacingMsg) - } - } - } - - override fun onBalanceClick(scopeInfo: ScopeInfo) { - model.showTransactions(scopeInfo) - } - -} diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -35,8 +35,9 @@ import org.json.JSONObject import java.util.LinkedList sealed class TransactionsResult { - class Error(val error: TalerErrorInfo) : TransactionsResult() - class Success(val transactions: List<Transaction>) : TransactionsResult() + data object None : TransactionsResult() + data class Error(val error: TalerErrorInfo) : TransactionsResult() + data class Success(val transactions: List<Transaction>) : TransactionsResult() } class TransactionManager( @@ -61,11 +62,15 @@ class TransactionManager( @UiThread get() = searchQuery.switchMap { query -> val scopeInfo = selectedScope - check(scopeInfo.value != null) { "Did not select scope before getting transactions" } - loadTransactions(query) - mTransactions[scopeInfo.value]!! // non-null because filled in [loadTransactions] + if (scopeInfo.value != null) { + loadTransactions(query) + mTransactions[scopeInfo.value]!! // non-null because filled in [loadTransactions] + } else { + MutableLiveData(TransactionsResult.None) + } } + @UiThread fun loadTransactions(searchQuery: String? = null) = scope.launch { val scopeInfo = selectedScope.value ?: return@launch diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt @@ -0,0 +1,360 @@ +/* + * 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 androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Badge +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.toRelativeTime +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.BalanceItem +import net.taler.wallet.balances.ScopeInfo.Exchange +import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.transactions.AmountType.Negative +import net.taler.wallet.transactions.AmountType.Neutral +import net.taler.wallet.transactions.AmountType.Positive +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.Aborted +import net.taler.wallet.transactions.TransactionMajorState.Aborting +import net.taler.wallet.transactions.TransactionMajorState.Done +import net.taler.wallet.transactions.TransactionMajorState.Failed +import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionMinorState.BalanceKycInit +import net.taler.wallet.transactions.TransactionMinorState.BalanceKycRequired +import net.taler.wallet.transactions.TransactionMinorState.BankConfirmTransfer +import net.taler.wallet.transactions.TransactionMinorState.KycRequired +import net.taler.wallet.transactions.TransactionsResult.Error +import net.taler.wallet.transactions.TransactionsResult.None +import net.taler.wallet.transactions.TransactionsResult.Success + +@Composable +fun TransactionsComposable( + balance: BalanceItem, + currencySpec: CurrencySpecification?, + txResult: TransactionsResult, + onTransactionClick: (tx: Transaction) -> Unit, + onShowBalancesClicked: () -> Unit, +) { + when (txResult) { + is None -> LoadingScreen() + is Error -> {} // TODO: render error! + is Success -> { + LazyColumn(Modifier.fillMaxHeight()) { + item { + TransactionsHeader( + balance = balance, + spec = currencySpec, + onShowBalancesClicked = onShowBalancesClicked, + ) + } + + items(txResult.transactions, key = { it.transactionId }) { tx -> + TransactionRow(tx, currencySpec) { + onTransactionClick(tx) + } + } + } + } + } +} + +@Composable +fun TransactionsHeader( + balance: BalanceItem, + spec: CurrencySpecification?, + onShowBalancesClicked: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedCard( + Modifier + .weight(1f) + .padding(8.dp) + .clickable { onShowBalancesClicked() }, + ) { + ListItem( + modifier = Modifier.animateContentSize(), + + headlineContent = { + Text( + getHeaderCurrency(balance, spec), + style = MaterialTheme.typography.titleMedium, + ) + }, + + supportingContent = { + if (balance.scopeInfo is Exchange) { + Text( + cleanExchange(balance.scopeInfo.url), + modifier = Modifier.padding(top = 3.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + + trailingContent = { + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + ) + } + + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.End, + ) { + Text( + stringResource(R.string.transactions_balance), + modifier = Modifier.padding(bottom = 6.dp), + style = MaterialTheme.typography.bodySmall, + ) + + Text( + balance.available.withSpec(spec).toString(showSymbol = false), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + } +} + +@Composable +fun TransactionRow( + tx: Transaction, + spec: CurrencySpecification?, + onTransactionClick: () -> Unit, +) { + val context = LocalContext.current + + ListItem( + modifier = Modifier + .defaultMinSize(minHeight = 80.dp) + .clickable { onTransactionClick() }, + + trailingContent = { + Box( + modifier = Modifier.padding(8.dp), + contentAlignment = Alignment.Center, + ) { + TransactionAmountInfo(tx, spec) + } + }, + + leadingContent = { + Box( + modifier = Modifier.padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Icon(painterResource(tx.icon), contentDescription = null) + } + }, + + headlineContent = { + Text( + tx.getTitle(context), + modifier = Modifier.padding(vertical = 3.dp), + style = MaterialTheme.typography.titleMedium, + ) + }, + + supportingContent = { + TransactionExtraInfo(tx) + }, + + overlineContent = { Text(tx.timestamp.ms.toRelativeTime(context).toString()) }, + ) +} + +@Composable +fun TransactionAmountInfo( + tx: Transaction, + spec: CurrencySpecification?, +) { + Column(horizontalAlignment = Alignment.End) { + ProvideTextStyle(MaterialTheme.typography.titleLarge) { + val amountStr = tx.amountEffective.withSpec(spec).toString(showSymbol = false) + when (tx.amountType) { + Positive -> Text( + stringResource(R.string.amount_positive, amountStr), + color = if (tx.txState.major == Pending) + Color.Unspecified else colorResource(R.color.green), + ) + Negative -> Text( + stringResource(R.string.amount_negative, amountStr), + color = if (tx.txState.major == Pending) + Color.Unspecified else MaterialTheme.colorScheme.error, + ) + Neutral -> Text(amountStr) + } + } + + if (tx.txState.major == Pending) { + Badge(Modifier.padding(top = 3.dp)) { + Text(stringResource(R.string.transaction_pending)) + } + } + } +} + +@Composable +fun TransactionExtraInfo(tx: Transaction) { + when { + tx.txState.major == Aborted -> Text( + stringResource(R.string.payment_aborted), + color = MaterialTheme.colorScheme.error, + ) + + tx.txState.major == Failed -> Text( + stringResource(R.string.payment_failed), + color = MaterialTheme.colorScheme.error, + ) + + tx.txState.major == Aborting -> Text( + stringResource(R.string.payment_aborting), + color = MaterialTheme.colorScheme.error, + ) + + tx.txState.major == Pending -> when(tx.txState.minor) { + BankConfirmTransfer -> Text(stringResource(R.string.withdraw_waiting_confirm)) + BalanceKycInit -> Text(stringResource(R.string.transaction_preparing_kyc)) + KycRequired -> Text(stringResource(R.string.transaction_action_kyc_bank)) + BalanceKycRequired -> Text(stringResource(R.string.transaction_action_kyc_balance)) + else -> Text(stringResource(R.string.transaction_pending)) + } + + tx is TransactionWithdrawal && !tx.confirmed -> Text(stringResource(R.string.withdraw_waiting_confirm)) + tx is TransactionPeerPushCredit && tx.info.summary != null -> Text(tx.info.summary) + tx is TransactionPeerPushDebit && tx.info.summary != null -> Text(tx.info.summary) + tx is TransactionPeerPullCredit && tx.info.summary != null -> Text(tx.info.summary) + tx is TransactionPeerPullDebit && tx.info.summary != null -> Text(tx.info.summary) + } +} + +@Composable +private fun getHeaderCurrency( + balance: BalanceItem, + spec: CurrencySpecification?, +) = if (spec != null) { + if (spec.symbol != null && spec.name != spec.symbol) { + // Name (symbol) + stringResource(R.string.transactions_currency, spec.name, spec.symbol!!) + } else if (spec.name != balance.currency) { + // Name (currency string) + stringResource(R.string.transactions_currency, spec.name, balance.currency) + } else balance.currency +} else balance.currency + +private val previewBalance = BalanceItem( + scopeInfo = Exchange("MXN", "https://exchange.taler.banxico.org.mx"), + available = Amount.fromJSONString("MXN:5.50"), + pendingIncoming = Amount.fromJSONString("MXN:1.40"), + pendingOutgoing = Amount.fromJSONString("MXN:0"), +) + +@Preview +@Composable +fun TransactionsComposableDonePreview() { + val t = TransactionWithdrawal( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Done), + txActions = listOf(Retry, Suspend, Abort), + exchangeBaseUrl = "https://exchange.demo.taler.net/", + withdrawalDetails = WithdrawalDetails.TalerBankIntegrationApi(false), + amountRaw = Amount.fromString("TESTKUDOS", "42.23"), + amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), + error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), + ) + + val transactions = listOf(t) + + TalerSurface { + TransactionsComposable( + balance = previewBalance, + currencySpec = null, + txResult = Success(transactions), + onTransactionClick = {}, + onShowBalancesClicked = {}, + ) + } +} + +@Preview +@Composable +fun TransactionsComposablePendingPreview() { + val t = TransactionWithdrawal( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending), + txActions = listOf(Retry, Suspend, Abort), + exchangeBaseUrl = "https://exchange.demo.taler.net/", + withdrawalDetails = WithdrawalDetails.TalerBankIntegrationApi(false), + amountRaw = Amount.fromString("TESTKUDOS", "42.23"), + amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), + error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), + ) + + val transactions = listOf(t) + + TalerSurface { + TransactionsComposable( + balance = previewBalance, + currencySpec = null, + txResult = Success(transactions), + onTransactionClick = {}, + onShowBalancesClicked = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt @@ -206,6 +206,11 @@ class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode. } private fun onTransactionsResult(result: TransactionsResult) = when (result) { + is TransactionsResult.None -> { + ui.list.fadeOut() + ui.emptyState.fadeIn() + } + is TransactionsResult.Error -> { ui.list.fadeOut() ui.emptyState.text = getString(R.string.transactions_error, result.error.userFacingMsg)