commit 493a7ffeb3d6b6c34643f24a3c9afdb705b920c9 parent b30fc9a3404b9c0f9a971432be773fe5aebaa98f Author: Iván Ávalos <avalos@disroot.org> Date: Fri, 25 Oct 2024 15:56:51 +0200 [wallet] Rewrite TransactionManager using flows Diffstat:
18 files changed, 226 insertions(+), 789 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/MainActivity.kt @@ -16,7 +16,6 @@ package net.taler.wallet -import android.annotation.SuppressLint import android.content.Intent import android.content.Intent.ACTION_VIEW import android.nfc.NdefMessage @@ -32,6 +31,9 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import androidx.core.view.GravityCompat.START +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController @@ -44,6 +46,7 @@ import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions.QR_CODE +import kotlinx.coroutines.launch import net.taler.common.EventObserver import net.taler.lib.android.TalerNfcService import net.taler.wallet.databinding.ActivityMainBinding @@ -85,16 +88,20 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { handleIntents(intent) - model.transactionManager.selectedTransaction.observe(this) { tx -> - TalerNfcService.clearUri(this) - - when (tx) { - is TransactionPeerPushDebit -> tx.talerUri - is TransactionPeerPullCredit -> tx.talerUri - else -> return@observe - }?.let { uri -> - Log.d(TAG, "Transaction ${tx.transactionId} selected with URI $uri") - TalerNfcService.setUri(this, uri) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + model.transactionManager.selectedTransaction.collect { tx -> + TalerNfcService.clearUri(this@MainActivity) + + when (tx) { + is TransactionPeerPushDebit -> tx.talerUri + is TransactionPeerPullCredit -> tx.talerUri + else -> return@collect + }?.let { uri -> + Log.d(TAG, "Transaction ${tx.transactionId} selected with URI $uri") + TalerNfcService.setUri(this@MainActivity, uri) + } + } } } @@ -124,7 +131,7 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { if (ui.drawerLayout.isDrawerOpen(START)) ui.drawerLayout.closeDrawer(START) else if (nav.currentDestination?.id == R.id.nav_main) { if (model.transactionManager.selectedScope.value != null) { - model.transactionManager.selectedScope.value = null + model.transactionManager.selectScope(null) } } else { super.onBackPressed() diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt @@ -16,7 +16,6 @@ package net.taler.wallet -import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -75,6 +74,7 @@ import net.taler.wallet.compose.DemandAttention import net.taler.wallet.compose.GridMenu import net.taler.wallet.compose.GridMenuItem import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.settings.SettingsFragment import net.taler.wallet.transactions.TransactionsResult @@ -145,8 +145,8 @@ 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() + val selectedScope by model.transactionManager.selectedScope.collectAsStateLifecycleAware() + val txResult by remember(selectedScope) { model.transactionManager.transactionsFlow(selectedScope) }.collectAsStateLifecycleAware() val selectedSpec = remember(selectedScope) { selectedScope?.let { model.balanceManager.getSpecForScopeInfo(it) } } Box(Modifier.padding(innerPadding).fillMaxSize()) { when (selectedTab) { @@ -166,7 +166,7 @@ class MainFragment: Fragment() { }, onShowBalancesClicked = { if (model.transactionManager.selectedScope.value != null) { - model.transactionManager.selectedScope.value = null + model.transactionManager.selectScope(null) } }, ) @@ -195,6 +195,14 @@ class MainFragment: Fragment() { override fun onStart() { super.onStart() model.balanceManager.loadBalances() + model.balanceManager.state.observe(viewLifecycleOwner) { res -> + if (res is BalanceState.Success) { + if (res.balances.size == 1) { + // pre-select on startup if it's the only one + model.transactionManager.selectScope(res.balances.first().scopeInfo) + } + } + } } private fun onSend() { diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -174,7 +174,8 @@ class MainViewModel( */ @UiThread fun showTransactions(scopeInfo: ScopeInfo) { - transactionManager.selectedScope.value = scopeInfo + Log.d(TAG, "selectedScope should change to $scopeInfo") + transactionManager.selectScope(scopeInfo) } @UiThread diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt @@ -68,13 +68,9 @@ fun BalancesComposable( 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 -> + items(state.balances, key = { it.scopeInfo.hashCode() }) { balance -> BalanceRow(balance) { onBalanceClicked(balance) } @@ -124,7 +120,7 @@ fun BalanceRow( ) }, overlineContent = { - ProvideTextStyle(MaterialTheme.typography.labelLarge) { + ProvideTextStyle(MaterialTheme.typography.bodySmall) { when (balance.scopeInfo) { is Exchange -> Text( stringResource( diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt @@ -201,7 +201,9 @@ fun CurrencyDropdown( OutlinedTextField( modifier = Modifier .clickable(onClick = { if (!readOnly) expanded = true }), - value = currencies[selectedIndex], + value = currencies.getOrNull(selectedIndex) + ?: initialCurrency // wallet is empty or currency is new + ?: error("no currency available"), onValueChange = { }, readOnly = true, enabled = false, diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt @@ -157,7 +157,7 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { } override fun onPeerReceive(item: ExchangeItem) { - transactionManager.selectedScope.value = item.scopeInfo + transactionManager.selectScope(item.scopeInfo) findNavController().navigate(R.id.nav_peer_pull) } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionAdapter.kt @@ -1,322 +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.transactions - -import android.content.Context -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.core.content.ContextCompat.getColor -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.ItemKeyProvider -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.widget.DiffUtil -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.Aborting -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 - - -internal class TransactionAdapter( - private val listener: OnTransactionClickListener, -) : Adapter<TransactionViewHolder>() { - - private var transactions: List<Transaction> = ArrayList() - private var networkAvailable: Boolean = true - private var currencySpec: CurrencySpecification? = null - - lateinit var tracker: SelectionTracker<String> - val keyProvider = TransactionKeyProvider() - - init { - setHasStableIds(false) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TransactionViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.list_item_transaction, parent, false) - return TransactionViewHolder(view) - } - - override fun getItemCount(): Int = transactions.size - - override fun onBindViewHolder(holder: TransactionViewHolder, position: Int) { - val transaction = transactions[position] - holder.bind(transaction, tracker.isSelected(transaction.transactionId)) - } - - fun update( - updatedTransactions: List<Transaction>? = null, - updatedNetworkAvailable: Boolean? = null, - updatedCurrencySpec: CurrencySpecification? = null, - ) { - val oldTransactions = transactions - val newTransactions = updatedTransactions ?: oldTransactions - val oldNetworkAvailable = networkAvailable - val newNetworkAvailable = updatedNetworkAvailable ?: oldNetworkAvailable - val oldCurrencySpec = currencySpec - val newCurrencySpec = updatedCurrencySpec ?: oldCurrencySpec - - val diffCallback = TransactionDiffCallback( - oldTransactions, - newTransactions, - oldNetworkAvailable, - newNetworkAvailable, - oldCurrencySpec, - newCurrencySpec, - ) - val diffResult = DiffUtil.calculateDiff(diffCallback) - diffResult.dispatchUpdatesTo(this) - - transactions = newTransactions - networkAvailable = newNetworkAvailable - currencySpec = newCurrencySpec - } - - fun selectAll() = transactions.forEach { - tracker.select(it.transactionId) - } - - 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) - private val time: TextView = v.findViewById(R.id.time) - private val amount: TextView = v.findViewById(R.id.amount) - private val pendingView: TextView = v.findViewById(R.id.pendingView) - - private val amountColor = amount.currentTextColor - 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) } - if (transaction.error == null) { - icon.setImageResource(transaction.icon) - } else { - icon.setImageResource(R.drawable.ic_error) - } - title.text = transaction.getTitle(context) - bindExtraInfo(transaction) - time.text = transaction.timestamp.ms.toRelativeTime(context) - bindAmount(transaction) - 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) { - when { - 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 == Aborting -> { - extraInfoView.setText(R.string.payment_aborting) - 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 - } - - BalanceKycInit -> { - extraInfoView.setText(R.string.transaction_preparing_kyc) - extraInfoView.setTextColor(amountColor) - extraInfoView.visibility = VISIBLE - } - - KycRequired -> { - extraInfoView.setText(R.string.transaction_action_kyc_bank) - extraInfoView.setTextColor(amountColor) - extraInfoView.visibility = VISIBLE - } - - BalanceKycRequired -> { - extraInfoView.setText(R.string.transaction_action_kyc_balance) - extraInfoView.setTextColor(amountColor) - extraInfoView.visibility = VISIBLE - } - - else -> { - extraInfoView.setText(R.string.transaction_pending) - extraInfoView.setTextColor(extraInfoColor) - extraInfoView.visibility = VISIBLE - } - } - - 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 - } - - // If network is available and the transaction is in a pending state, - // show error message, otherwise it might just be a network error. - // https://bugs.gnunet.org/view.php?id=8912 - if (transaction.error != null - && (transaction.txState.major !in listOf(Pending, Aborting) || networkAvailable) - ) { - extraInfoView.text = - context.getString(R.string.payment_error, transaction.error!!.userFacingMsg) - extraInfoView.setTextColor(red) - extraInfoView.visibility = VISIBLE - } - } - - private fun bindAmount(transaction: Transaction) { - 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.txState.major == Pending) amountColor else green) - } - - AmountType.Negative -> { - amount.text = context.getString(R.string.amount_negative, amountStr) - amount.setTextColor(if (transaction.txState.major == Pending) amountColor else red) - } - - AmountType.Neutral -> { - amount.text = amountStr - amount.setTextColor(amountColor) - } - }.exhaustive - } - } - - internal inner class TransactionKeyProvider : ItemKeyProvider<String>(SCOPE_MAPPED) { - override fun getKey(position: Int) = transactions[position].transactionId - override fun getPosition(key: String): Int { - return transactions.indexOfFirst { it.transactionId == key } - } - } - -} - -internal class TransactionLookup( - private val list: RecyclerView, - 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.bindingAdapterPosition - if (position < 0) return null - return object : ItemDetails<String>() { - override fun getPosition(): Int = position - override fun getSelectionKey(): String = adapter.keyProvider.getKey(position) - } - } - return null - } -} - -internal class TransactionDiffCallback( - private val oldList: List<Transaction>, - private val newList: List<Transaction>, - private val oldNetworkAvailable: Boolean?, - private val newNetworkAvailable: Boolean?, - private val oldCurrencySpec: CurrencySpecification?, - private val newCurrencySpec: CurrencySpecification?, -): DiffUtil.Callback() { - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldTx = oldList[oldItemPosition] - val newTx = newList[newItemPosition] - - return oldTx.transactionId == newTx.transactionId - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldTx = oldList[oldItemPosition] - val newTx = newList[newItemPosition] - - return oldTx.txState == newTx.txState - && oldTx.error == newTx.error - && oldNetworkAvailable == newNetworkAvailable - && oldCurrencySpec == newCurrencySpec - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDepositFragment.kt @@ -20,9 +20,11 @@ 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.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.deposit.TransactionDepositComposable class TransactionDepositFragment : TransactionDetailFragment() { @@ -34,13 +36,14 @@ class TransactionDepositFragment : TransactionDetailFragment() { ): View = ComposeView(requireContext()).apply { setContent { TalerSurface { - val t = transactionManager.selectedTransaction.observeAsState().value - if (t is TransactionDeposit) TransactionDepositComposable( - t = t, + val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() + val tx = remember(t) { t } + if (tx is TransactionDeposit) TransactionDepositComposable( + t = tx, devMode = devMode, - spec = balanceManager.getSpecForCurrency(t.amountRaw.currency), + spec = balanceManager.getSpecForCurrency(tx.amountRaw.currency), ) { - onTransitionButtonClicked(t, it) + onTransitionButtonClicked(tx, 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 @@ -21,8 +21,12 @@ import android.util.Log import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch import net.taler.common.showError import net.taler.wallet.MainViewModel import net.taler.wallet.R @@ -44,10 +48,14 @@ abstract class TransactionDetailFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - transactionManager.selectedTransaction.observe(viewLifecycleOwner) { - requireActivity().apply { - it?.generalTitleRes?.let { - title = getString(it) + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + transactionManager.selectedTransaction.collect { + requireActivity().apply { + it?.generalTitleRes?.let { + title = getString(it) + } + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDummyFragment.kt @@ -26,12 +26,13 @@ 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.runtime.getValue 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 +import net.taler.wallet.compose.collectAsStateLifecycleAware class TransactionDummyFragment : TransactionDetailFragment() { @@ -42,8 +43,8 @@ class TransactionDummyFragment : TransactionDetailFragment() { ): View = ComposeView(requireContext()).apply { setContent { TalerSurface { - val t = transactionManager.selectedTransaction.observeAsState(null).value - if (t is DummyTransaction) TransactionDummyComposable(t) + val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() + (t as? DummyTransaction)?.let { TransactionDummyComposable(it) } } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt @@ -29,7 +29,7 @@ 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.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -45,6 +45,7 @@ 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.compose.collectAsStateLifecycleAware import net.taler.wallet.transactions.LossEventType.DenomExpired import net.taler.wallet.transactions.LossEventType.DenomUnoffered import net.taler.wallet.transactions.LossEventType.DenomVanished @@ -62,13 +63,13 @@ class TransactionLossFragment: TransactionDetailFragment() { savedInstanceState: Bundle? ): View = ComposeView(requireContext()).apply { setContent { - val t = transactionManager.selectedTransaction.observeAsState().value + val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() val spec = scope?.let { balanceManager.getSpecForScopeInfo(it) } TalerSurface { - if (t is TransactionDenomLoss) { - TransitionLossComposable(t, devMode, spec) { - onTransitionButtonClicked(t, it) + (t as? TransactionDenomLoss)?.let { tx -> + TransitionLossComposable(tx, devMode, spec) { + onTransitionButtonClicked(tx, it) } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -1,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2020 Taler Systems S.A. + * (C) 2024 Taler Systems S.A. * * GNU Taler is free software; you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software @@ -18,21 +18,20 @@ 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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import net.taler.wallet.TAG +import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi 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 { data object None : TransactionsResult() @@ -44,80 +43,108 @@ class TransactionManager( private val api: WalletBackendApi, private val scope: CoroutineScope, ) { + private val allTransactions = HashMap<ScopeInfo, List<Transaction>>() + private val mTransactions = HashMap<ScopeInfo, MutableStateFlow<TransactionsResult>>() + private val mSelectedTransaction = MutableStateFlow<Transaction?>(null) + private val mSelectedScope = MutableStateFlow<ScopeInfo?>(null) + private val mSearchQuery = MutableStateFlow<String?>(null) + + val selectedTransaction = mSelectedTransaction.asStateFlow() + val selectedScope = mSelectedScope.asStateFlow() + val searchQuery = mSearchQuery.asStateFlow() + + // This function must be called ONLY when scopeInfo / searchQuery change! + // Use remember() {} in Compose to prevent multiple calls during recomposition + fun transactionsFlow( + scopeInfo: ScopeInfo? = null, + searchQuery: String? = null, + ): StateFlow<TransactionsResult> { + loadTransactions() + return if (scopeInfo != null) { + loadTransactions(scopeInfo, searchQuery) + mTransactions[scopeInfo]?.asStateFlow() + ?: MutableStateFlow(TransactionsResult.None) + } else { + MutableStateFlow(TransactionsResult.None) + } + } - private val mProgress = MutableLiveData<Boolean>() - val progress: LiveData<Boolean> = mProgress + @UiThread + fun loadTransactions( + scopeInfo: ScopeInfo? = null, + searchQuery: String? = null, + ) { + Log.d(TAG, "loadTransactions($scopeInfo, $searchQuery)") + val s = scopeInfo ?: mSelectedScope.value ?: run { + MutableStateFlow(TransactionsResult.None) + return + } - // 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 selectedScope: MutableLiveData<ScopeInfo?> = MutableLiveData(null) + // initialize key with empty state flow + if (mTransactions[s] == null) { + mTransactions[s] = MutableStateFlow(TransactionsResult.None) + } - val searchQuery = MutableLiveData<String>(null) - 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 scopeInfo = selectedScope - if (scopeInfo.value != null) { - loadTransactions(query) - mTransactions[scopeInfo.value]!! // non-null because filled in [loadTransactions] - } else { - MutableLiveData(TransactionsResult.None) + scope.launch { + // return cached transactions if available + if(searchQuery == null) allTransactions[s]?.let { txs -> + mTransactions[s]?.value = TransactionsResult.Success(txs) } - } + // ...then fetch new ones + val res = getTransactions(s, searchQuery) + if (res is TransactionsResult.Success) { + allTransactions[s] = res.transactions + } - @UiThread - fun loadTransactions(searchQuery: String? = null) = scope.launch { - val scopeInfo = selectedScope.value ?: return@launch - val liveData = mTransactions.getOrPut(scopeInfo) { MutableLiveData() } - if (searchQuery == null && allTransactions.containsKey(scopeInfo)) { - liveData.value = TransactionsResult.Success(allTransactions[scopeInfo]!!) + // ...and then emit them when available + mTransactions[s]?.value = res } - if (liveData.value == null) mProgress.value = true + } + private suspend fun getTransactions(scope: ScopeInfo, searchQuery: String?): TransactionsResult { + var result: TransactionsResult = TransactionsResult.None api.request("getTransactions", Transactions.serializer()) { if (searchQuery != null) put("search", searchQuery) - put("scopeInfo", JSONObject(Json.encodeToString(scopeInfo))) - }.onError { - liveData.postValue(TransactionsResult.Error(it)) - mProgress.postValue(false) - }.onSuccess { result -> - val transactions = LinkedList(result.transactions) + put("scopeInfo", JSONObject(BackendManager.json.encodeToString(scope))) + }.onError { error -> + Log.e(TAG, "Error: getTransactions error result: $error") + result = TransactionsResult.Error(error) + }.onSuccess { res -> 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 scope info - if (searchQuery == null) allTransactions[scopeInfo] = transactions + result = TransactionsResult.Success(res + .transactions + .sortedWith(comparator) + .reversed()) } + + return result } - /** - * Returns true if given [transactionId] was found and selected, false otherwise. - */ - @UiThread - suspend fun selectTransaction(transactionId: String): Boolean { + suspend fun getTransactionById(id: String): Transaction? { var transaction: Transaction? = null api.request("getTransactionById", Transaction.serializer()) { - put("transactionId", transactionId) + put("transactionId", id) }.onError { Log.e(TAG, "Error getting transaction $it") }.onSuccess { result -> transaction = result } - return if (transaction != null) { - mSelectedTransaction.value = transaction - true + + return transaction + } + + /** + * Returns true if given [transactionId] was found and selected, false otherwise. + */ + @UiThread + suspend fun selectTransaction(transactionId: String): Boolean { + val transaction = getTransactionById(transactionId) + if (transaction != null) { + mSelectedTransaction.emit(transaction) + return true } else { - false + return false } } @@ -125,34 +152,24 @@ class TransactionManager( fun updateTransactionIfSelected(id: String) = scope.launch { val selectedTransaction = selectedTransaction.value if (selectedTransaction?.transactionId != id) return@launch - mProgress.value = true - api.request("getTransactionById", Transaction.serializer()) { - put("transactionId", id) - }.onError { - mProgress.value = false - Log.e(TAG, "Error updating selected transaction $it") - }.onSuccess { result -> - mProgress.value = false - if (result.transactionId != selectedTransaction.transactionId) return@onSuccess - Log.d(TAG, "updating selected transaction: ${result.transactionId}") - mSelectedTransaction.value = result - } + getTransactionById(id)?.let { tx -> + if (tx.transactionId == selectedTransaction.transactionId) { + Log.d(TAG, "updating selected transaction: ${tx.transactionId}") + mSelectedTransaction.value = tx + } + } ?: Log.d(TAG, "Error updating selected transaction $id") } - 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(tx: Transaction) = scope.launch { + mSelectedTransaction.value = tx + } + + fun selectScope(scopeInfo: ScopeInfo?) = scope.launch { + mSelectedScope.value = scopeInfo } - fun selectTransaction(transaction: Transaction) { - mSelectedTransaction.postValue(transaction) + fun setSearchQuery(searchQuery: String?) = scope.launch { + mSearchQuery.value = searchQuery } fun deleteTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) = @@ -233,5 +250,4 @@ class TransactionManager( } } } - -} +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPaymentFragment.kt @@ -20,9 +20,10 @@ 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.runtime.getValue import androidx.compose.ui.platform.ComposeView import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.launchInAppBrowser import net.taler.wallet.payment.TransactionPaymentComposable @@ -35,16 +36,18 @@ class TransactionPaymentFragment : TransactionDetailFragment() { ): 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) - } - ) + val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() + (t as? TransactionPayment)?.let { tx -> + TransactionPaymentComposable(tx, devMode, + balanceManager.getSpecForCurrency(tx.amountRaw.currency), + onFulfill = { url -> + launchInAppBrowser(requireContext(), url) + }, + onTransition = { + onTransitionButtonClicked(tx, it) + } + ) + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionPeerFragment.kt @@ -29,7 +29,7 @@ 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.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier @@ -44,6 +44,7 @@ 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.compose.collectAsStateLifecycleAware import net.taler.wallet.launchInAppBrowser import net.taler.wallet.peer.TransactionPeerPullCreditComposable import net.taler.wallet.peer.TransactionPeerPullDebitComposable @@ -59,12 +60,15 @@ class TransactionPeerFragment : TransactionDetailFragment(), ActionListener { ): View = ComposeView(requireContext()).apply { setContent { TalerSurface { - val t = transactionManager.selectedTransaction.observeAsState(null).value - if (t != null) TransactionPeerComposable(t, devMode, - balanceManager.getSpecForCurrency(t.amountRaw.currency), - this@TransactionPeerFragment, - ) { - onTransitionButtonClicked(t, it) + val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() + t?.let { tx -> + TransactionPeerComposable( + tx, devMode, + balanceManager.getSpecForCurrency(tx.amountRaw.currency), + this@TransactionPeerFragment, + ) { + onTransitionButtonClicked(tx, it) + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefreshFragment.kt @@ -29,7 +29,7 @@ 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.runtime.getValue import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -45,6 +45,7 @@ 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.compose.collectAsStateLifecycleAware import net.taler.wallet.transactions.TransactionAction.Abort import net.taler.wallet.transactions.TransactionAction.Retry import net.taler.wallet.transactions.TransactionAction.Suspend @@ -59,11 +60,13 @@ class TransactionRefreshFragment : TransactionDetailFragment() { ): 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) + val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() + (t as? TransactionRefresh)?.let { tx -> + TransactionRefreshComposable(tx, devMode, + balanceManager.getSpecForCurrency(tx.amountRaw.currency), + ) { + onTransitionButtonClicked(tx, it) + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionRefundFragment.kt @@ -20,9 +20,10 @@ 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.runtime.getValue import androidx.compose.ui.platform.ComposeView import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.refund.TransactionRefundComposable class TransactionRefundFragment : TransactionDetailFragment() { @@ -34,11 +35,13 @@ class TransactionRefundFragment : TransactionDetailFragment() { ): 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) + val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() + (t as? TransactionRefund)?.let { tx -> + TransactionRefundComposable(tx, devMode, + balanceManager.getSpecForCurrency(tx.amountRaw.currency) + ) { + onTransitionButtonClicked(tx, it) + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionWithdrawalFragment.kt @@ -20,7 +20,7 @@ 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.runtime.getValue import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController @@ -28,6 +28,7 @@ import net.taler.wallet.MainViewModel import net.taler.wallet.R import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.launchInAppBrowser import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi @@ -45,14 +46,16 @@ class TransactionWithdrawalFragment : TransactionDetailFragment(), ActionListene ): 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) + val t by transactionManager.selectedTransaction.collectAsStateLifecycleAware() + (t as? TransactionWithdrawal)?.let { tx -> + TransactionWithdrawalComposable( + t = tx, + devMode = devMode, + spec = balanceManager.getSpecForCurrency(tx.amountRaw.currency), + actionListener = this@TransactionWithdrawalFragment, + ) { + onTransitionButtonClicked(tx, it) + } } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsFragment.kt @@ -1,300 +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.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.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.SearchView.OnQueryTextListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.selection.SelectionPredicates -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.balances.ScopeInfo -import net.taler.wallet.cleanExchange -import net.taler.wallet.databinding.FragmentTransactionsBinding -import net.taler.wallet.showError - -interface OnTransactionClickListener { - fun onTransactionClicked(transaction: Transaction) -} - -class TransactionsFragment : Fragment(), OnTransactionClickListener, ActionMode.Callback { - - private val model: MainViewModel by activityViewModels() - private val transactionManager by lazy { model.transactionManager } - private val balanceManager by lazy { model.balanceManager } - private val networkManager by lazy { model.networkManager } - - private lateinit var ui: FragmentTransactionsBinding - private val transactionAdapter by lazy { TransactionAdapter(this) } - private val scopeInfo by lazy { transactionManager.selectedScope.value!! } - private var tracker: SelectionTracker<String>? = null - private var actionMode: ActionMode? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - ui = FragmentTransactionsBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - ui.list.apply { - adapter = transactionAdapter - addItemDecoration(DividerItemDecoration(context, VERTICAL)) - } - - val tracker = SelectionTracker.Builder( - "transaction-selection-id", - ui.list, - transactionAdapter.keyProvider, - TransactionLookup(ui.list, transactionAdapter), - StorageStrategy.createStringStorage() - ).withSelectionPredicate( - SelectionPredicates.createSelectAnything() - ).build() - savedInstanceState?.let { tracker.onRestoreInstanceState(it) } - transactionAdapter.tracker = tracker - this.tracker = tracker - tracker.addObserver(object : SelectionTracker.SelectionObserver<String>() { - override fun onItemStateChanged(key: String, selected: Boolean) { - if (selected && actionMode == null) { - actionMode = requireActivity().startActionMode(this@TransactionsFragment) - updateActionModeTitle() - } else if (actionMode != null) { - if (selected || tracker.hasSelection()) { - updateActionModeTitle() - } else { - actionMode!!.finish() - } - } - } - }) - - balanceManager.state.observe(viewLifecycleOwner) { state -> - if (state !is Success) return@observe - val balances = state.balances - - balances.find { it.scopeInfo == scopeInfo }?.let { balance -> - val spec = balanceManager.getSpecForScopeInfo(scopeInfo) - - ui.actionsBar.amount.text = balance.available.toString(showSymbol = false) - ui.actionsBar.currencyLabel.text = if (spec != null) { - if (spec.symbol != null && spec.name != spec.symbol) { - // Name (symbol) - getString(R.string.transactions_currency, spec.name, spec.symbol) - } else if (spec.name != balance.currency) { - // Name (currency string) - getString(R.string.transactions_currency, spec.name, balance.currency) - } else balance.currency - } else balance.currency - - if (balance.scopeInfo is ScopeInfo.Exchange) { - ui.actionsBar.exchangeLabel.text = cleanExchange(balance.scopeInfo.url) - ui.actionsBar.exchangeLabel.visibility = VISIBLE - } else { - ui.actionsBar.exchangeLabel.visibility = GONE - } - transactionAdapter.update(updatedCurrencySpec = balance.available.spec) - } - } - - ui.actionsBar.currencyCard.setOnClickListener { - requireActivity().onBackPressed() - } - - // TODO: refactor and unify progress bar handling - // transactionManager.progress.observe(viewLifecycleOwner) { show -> - // if (show) ui.progressBar.fadeIn() else ui.progressBar.fadeOut() - // } - - transactionManager.transactions.observe(viewLifecycleOwner) { result -> - onTransactionsResult(result) - } - - networkManager.networkStatus.observe(viewLifecycleOwner) { state -> - transactionAdapter.update(updatedNetworkAvailable = state) - } - } - - override fun onStart() { - super.onStart() - requireActivity().title = getString(R.string.transactions_title) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - 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)) - } - - private fun setupSearch(item: MenuItem) { - item.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem) = true - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - onSearchClosed() - return true - } - }) - val searchView = item.actionView as SearchView - searchView.setOnQueryTextListener(object : OnQueryTextListener { - override fun onQueryTextChange(newText: String) = false - override fun onQueryTextSubmit(query: String): Boolean { - // 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 - searchView.clearFocus() - onSearch(query) - return true - } - }) - } - - override fun onTransactionClicked(transaction: Transaction) { - if (actionMode != null) return // don't react on clicks while in action mode - if (transaction.detailPageNav != 0) { - transactionManager.selectTransaction(transaction) - findNavController().navigate(transaction.detailPageNav) - } - } - - 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) - ui.emptyState.fadeIn() - } - - is TransactionsResult.Success -> { - if (result.transactions.isEmpty()) { - val isSearch = transactionManager.searchQuery.value != null - ui.emptyState.setText(if (isSearch) R.string.transactions_empty_search else R.string.transactions_empty) - ui.emptyState.fadeIn() - ui.list.fadeOut() - } else { - val state = networkManager.networkStatus.value ?: true - ui.emptyState.fadeOut() - transactionAdapter.update(result.transactions, state) - ui.list.fadeIn() - } - } - } - - private fun onSearch(query: String) { - ui.list.fadeOut() - // TODO: refactor and unify progress bar handling - // ui.progressBar.fadeIn() - transactionManager.searchQuery.value = query - } - - private fun onSearchClosed() { - transactionManager.searchQuery.value = null - } - - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - val inflater = mode.menuInflater - inflater.inflate(R.menu.transactions_action_mode, menu) - return true - } - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - return false // no update needed - } - - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - when (item.itemId) { - R.id.transaction_delete -> { - 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 - } - - override fun onDestroyActionMode(mode: ActionMode) { - tracker?.clearSelection() - actionMode = null - } - - private fun updateActionModeTitle() { - tracker?.selection?.size()?.toString()?.let { num -> - actionMode?.title = num - } - } -}