taler-android

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

commit 5d9d788e4bd1356f5485aae3c988ba16a3e52594
parent 493a7ffeb3d6b6c34643f24a3c9afdb705b920c9
Author: Iván Ávalos <avalos@disroot.org>
Date:   Fri, 25 Oct 2024 20:42:57 +0200

[wallet] Add multi-selection to new transaction list

Diffstat:
Mwallet/src/main/java/net/taler/wallet/MainActivity.kt | 13-------------
Mwallet/src/main/java/net/taler/wallet/MainFragment.kt | 15++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt | 4++++
Awallet/src/main/java/net/taler/wallet/compose/SelectionModeTopAppBar.kt | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
5 files changed, 241 insertions(+), 32 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/MainActivity.kt @@ -30,7 +30,6 @@ import android.view.View.VISIBLE 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 @@ -126,18 +125,6 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { } } - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - 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.selectScope(null) - } - } else { - super.onBackPressed() - } - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleIntents(intent) diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt @@ -20,6 +20,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState @@ -59,6 +61,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -76,7 +79,6 @@ 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 class MainFragment: Fragment() { @@ -144,10 +146,16 @@ class MainFragment: Fragment() { } } ) { innerPadding -> + val context = LocalContext.current val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) 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) } } + + BackHandler(selectedScope != null) { + model.transactionManager.selectScope(null) + } + Box(Modifier.padding(innerPadding).fillMaxSize()) { when (selectedTab) { Tab.BALANCES -> BalancesComposable( @@ -164,6 +172,11 @@ class MainFragment: Fragment() { findNavController().navigate(tx.detailPageNav) } }, + onTransactionsDelete = { txIds -> + model.transactionManager.deleteTransactions(txIds) { error -> + Toast.makeText(context, error.userFacingMsg, Toast.LENGTH_LONG).show() + } + }, onShowBalancesClicked = { if (model.transactionManager.selectedScope.value != null) { model.transactionManager.selectScope(null) diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt @@ -61,6 +61,7 @@ fun BalancesComposable( selectedCurrencySpec: CurrencySpecification?, onBalanceClicked: (balance: BalanceItem) -> Unit, onTransactionClicked: (tx: Transaction) -> Unit, + onTransactionsDelete: (txIds: List<String>) -> Unit, onShowBalancesClicked: () -> Unit, ) { when (state) { @@ -90,6 +91,7 @@ fun BalancesComposable( currencySpec = selectedCurrencySpec, txResult = txResult, onTransactionClick = onTransactionClicked, + onTransactionsDelete = onTransactionsDelete, onShowBalancesClicked = onShowBalancesClicked, ) } ?: error("no balance matching scopeInfo") @@ -216,6 +218,7 @@ fun BalancesComposablePreview() { selectedCurrencySpec = null, onBalanceClicked = {}, onTransactionClicked = {}, + onTransactionsDelete = {}, onShowBalancesClicked = {}, ) } @@ -232,6 +235,7 @@ fun BalancesComposableEmptyPreview() { selectedCurrencySpec = null, onBalanceClicked = {}, onTransactionClicked = {}, + onTransactionsDelete = {}, onShowBalancesClicked = {}, ) } diff --git a/wallet/src/main/java/net/taler/wallet/compose/SelectionModeTopAppBar.kt b/wallet/src/main/java/net/taler/wallet/compose/SelectionModeTopAppBar.kt @@ -0,0 +1,85 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.compose + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.SelectAll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.res.stringResource +import net.taler.wallet.R + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun SelectionModeTopAppBar( + selectedItems: SnapshotStateList<String>, + resetSelectionMode: () -> Unit, + onSelectAllClicked: () -> Unit, + onDeleteClicked: () -> Unit, +) { + TopAppBar( + title = { + Text( + text = "${selectedItems.size} selected", + style = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + ) + }, + + navigationIcon = { + IconButton(resetSelectionMode) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + + actions = { + IconButton(onDeleteClicked) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringResource(R.string.transactions_delete), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton(onSelectAllClicked) { + Icon( + imageVector = Icons.Rounded.SelectAll, + contentDescription = stringResource(R.string.transactions_select_all), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt @@ -16,27 +16,42 @@ package net.taler.wallet.transactions +import androidx.activity.compose.BackHandler import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable 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.fillMaxSize 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.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.RadioButtonUnchecked +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.Icon import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -58,6 +73,7 @@ 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.SelectionModeTopAppBar import net.taler.wallet.compose.TalerSurface import net.taler.wallet.transactions.AmountType.Negative import net.taler.wallet.transactions.AmountType.Neutral @@ -84,12 +100,66 @@ fun TransactionsComposable( currencySpec: CurrencySpecification?, txResult: TransactionsResult, onTransactionClick: (tx: Transaction) -> Unit, + onTransactionsDelete: (txIds: List<String>) -> Unit, onShowBalancesClicked: () -> Unit, -) { - when (txResult) { - is None -> LoadingScreen() - is Error -> {} // TODO: render error! - is Success -> { +) = when (txResult) { + is None -> LoadingScreen() + is Error -> {} // TODO: render error! + is Success -> { + var showDeleteDialog by remember { mutableStateOf(false) } + var selectionMode by remember { mutableStateOf(false) } + val selectedItems = remember { mutableStateListOf<String>() } + + if (showDeleteDialog) AlertDialog( + title = { Text(stringResource(R.string.transactions_delete_dialog_title)) }, + text = { Text(stringResource(R.string.transactions_delete_dialog_message)) }, + onDismissRequest = { showDeleteDialog = false }, + confirmButton = { + TextButton(onClick = { + onTransactionsDelete(selectedItems) + selectedItems.clear() + selectionMode = false + showDeleteDialog = false + }) { + Text(stringResource(R.string.transactions_delete)) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteDialog = false + }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + + BackHandler(selectionMode) { + selectionMode = false + selectedItems.clear() + } + + LaunchedEffect(selectionMode, selectedItems.size) { + if (selectionMode && selectedItems.isEmpty()) { + selectionMode = false + } + } + + Column(Modifier.fillMaxSize()) { + if (selectionMode) SelectionModeTopAppBar( + selectedItems = selectedItems, + resetSelectionMode = { + selectionMode = false + selectedItems.clear() + }, + onSelectAllClicked = { + selectedItems.clear() + selectedItems += txResult.transactions.map { it.transactionId } + }, + onDeleteClicked = { + showDeleteDialog = true + }, + ) + LazyColumn(Modifier.fillMaxHeight()) { item { TransactionsHeader( @@ -100,9 +170,36 @@ fun TransactionsComposable( } items(txResult.transactions, key = { it.transactionId }) { tx -> - TransactionRow(tx, currencySpec) { - onTransactionClick(tx) - } + val isSelected = selectedItems.contains(tx.transactionId) + + TransactionRow( + tx, currencySpec, + isSelected = isSelected, + selectionMode = selectionMode, + onTransactionClick = { + if (selectionMode) { + if (isSelected) { + selectedItems.remove(tx.transactionId) + } else { + selectedItems.add(tx.transactionId) + } + } else { + onTransactionClick(tx) + } + }, + onTransactionSelect = { + if (selectionMode) { + if (isSelected) { + selectedItems.remove(tx.transactionId) + } else { + selectedItems.add(tx.transactionId) + } + } else { + selectionMode = true + selectedItems.add(tx.transactionId) + } + }, + ) } } } @@ -127,14 +224,12 @@ fun TransactionsHeader( ) { ListItem( modifier = Modifier.animateContentSize(), - headlineContent = { Text( getHeaderCurrency(balance, spec), style = MaterialTheme.typography.titleMedium, ) }, - supportingContent = { if (balance.scopeInfo is Exchange) { Text( @@ -144,7 +239,6 @@ fun TransactionsHeader( ) } }, - trailingContent = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) } @@ -170,19 +264,25 @@ fun TransactionsHeader( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun TransactionRow( tx: Transaction, spec: CurrencySpecification?, + isSelected: Boolean, + selectionMode: Boolean, onTransactionClick: () -> Unit, + onTransactionSelect: () -> Unit, ) { val context = LocalContext.current ListItem( modifier = Modifier .defaultMinSize(minHeight = 80.dp) - .clickable { onTransactionClick() }, - + .combinedClickable( + onClick = onTransactionClick, + onLongClick = onTransactionSelect, + ), trailingContent = { Box( modifier = Modifier.padding(8.dp), @@ -191,16 +291,28 @@ fun TransactionRow( TransactionAmountInfo(tx, spec) } }, - leadingContent = { Box( modifier = Modifier.padding(8.dp), contentAlignment = Alignment.Center, ) { - Icon(painterResource(tx.icon), contentDescription = null) + if (!selectionMode) { + Icon(painterResource(tx.icon), contentDescription = null) + } else if (isSelected) { + Icon( + Icons.Rounded.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + Icons.Rounded.RadioButtonUnchecked, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + ) + } } }, - headlineContent = { Text( tx.getTitle(context), @@ -208,12 +320,17 @@ fun TransactionRow( style = MaterialTheme.typography.titleMedium, ) }, - supportingContent = { TransactionExtraInfo(tx) }, - overlineContent = { Text(tx.timestamp.ms.toRelativeTime(context).toString()) }, + colors = ListItemDefaults.colors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + ListItemDefaults.containerColor + } + ) ) } @@ -326,6 +443,7 @@ fun TransactionsComposableDonePreview() { currencySpec = null, txResult = Success(transactions), onTransactionClick = {}, + onTransactionsDelete = {}, onShowBalancesClicked = {}, ) } @@ -354,6 +472,7 @@ fun TransactionsComposablePendingPreview() { currencySpec = null, txResult = Success(transactions), onTransactionClick = {}, + onTransactionsDelete = {}, onShowBalancesClicked = {}, ) }