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:
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)