taler-android

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

commit f458809ebcbf614f6bca4c41240604633d0f111d
parent fafac2b94058cf65a59ca55fa8d105c01d1282c0
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 29 Oct 2026 14:05:22 +0100

[wallet] hide new balances screen behind dev mode toggle

Diffstat:
Mwallet/src/main/java/net/taler/wallet/WalletNavHost.kt | 1+
Awallet/src/main/java/net/taler/wallet/balances/LegacyBalancesComposable.kt | 471+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/main/LegacyMainScreen.kt | 396+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/main/MainScreen.kt | 215+++++++++++--------------------------------------------------------------------
Awallet/src/main/java/net/taler/wallet/main/NewMainScreen.kt | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt | 24++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt | 459++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Awallet/src/main/java/net/taler/wallet/transactions/LegacyTransactionsComposable.kt | 590+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/proto/user_prefs.proto | 2++
Mwallet/src/main/res/values/strings.xml | 2++
11 files changed, 2232 insertions(+), 400 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt @@ -130,6 +130,7 @@ fun WalletNavHost( model = model, onNavigate = onNavigate, onFulfillPayment = onFulfillPayment, + onShowError = onShowError, ) } composable<WalletDestination.Transactions> { backStackEntry -> diff --git a/wallet/src/main/java/net/taler/wallet/balances/LegacyBalancesComposable.kt b/wallet/src/main/java/net/taler/wallet/balances/LegacyBalancesComposable.kt @@ -0,0 +1,471 @@ +/* + * This file is part of GNU Taler + * (C) 2025 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.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +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.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.EventRepeat +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Badge +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +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.PlainTooltip +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import net.taler.common.Amount +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.ScopeInfo.Auditor +import net.taler.wallet.balances.ScopeInfo.Exchange +import net.taler.wallet.balances.ScopeInfo.ExchangeLegacyKeys +import net.taler.wallet.balances.ScopeInfo.Global +import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.cardPaddings +import net.taler.wallet.donau.DonauSummaryItem +import net.taler.wallet.ui.theme.TalerTheme + +/** + * Classic balances overview. Pending amounts are only shown when + * [onPendingClicked] is provided; the experimental pager's list page omits them + * because each balance has its own card with pending chips. + */ +@Composable +fun LegacyBalancesComposable( + innerPadding: PaddingValues, + state: BalanceState, + devMode: Boolean, + networkStatus: Boolean, + onWithdrawMoneyClicked: () -> Unit, + onGetDemoMoneyClicked: () -> Unit, + onBalanceClicked: (balance: BalanceItem) -> Unit, + onStatementClicked: (host: String) -> Unit, + onPendingClicked: ((balance: BalanceItem) -> Unit)? = null, + onShowDiscounts: (() -> Unit)? = null, + onShowPasses: (() -> Unit)? = null, +) { + if (state.showWelcome()) { + EmptyBalancesComposable( + innerPadding = innerPadding, + networkStatus = networkStatus, + onWithdrawMoneyClicked, + onGetDemoMoneyClicked, + ) + + return + } + + when (state) { + is BalanceState.Loading -> LoadingScreen() + is BalanceState.Error -> ErrorComposable(state.error, + devMode = devMode, + modifier = Modifier.fillMaxSize()) + is BalanceState.Success -> LazyColumn( + Modifier + .consumeWindowInsets(innerPadding) + .fillMaxSize(), + contentPadding = innerPadding, + ) { + items(state.balances, key = { it.scopeInfo.hashCode() }) { balance -> + LegacyBalanceRow( + balance, + onClick = { onBalanceClicked(balance) }, + onPendingClick = onPendingClicked?.let { { it(balance) } }, + ) + } + + if (state.donauSummary.isNotEmpty()) stickyHeader { + LegacySectionHeader { Text(stringResource(R.string.balances_section_statements)) } + } + + items(state.donauSummary) { statement -> + LegacyStatementRow( + statement, + onClick = { onStatementClicked(statement.donauBaseUrl) }, + ) + } + + if (onShowDiscounts != null || onShowPasses != null) { + stickyHeader { + LegacySectionHeader { Text(stringResource(R.string.balances_section_tokens)) } + } + + if (onShowDiscounts != null) item { + OutlinedCard(Modifier.cardPaddings()) { + ListItem( + modifier = Modifier.clickable { onShowDiscounts() }, + leadingContent = { Icon( + painterResource(R.drawable.ic_discounts), + contentDescription = null, + ) }, + headlineContent = { + Text( + stringResource(R.string.discounts_title), + style = MaterialTheme.typography.titleMedium, + ) + }, + ) + } + } + + if (onShowPasses != null) item { + OutlinedCard(Modifier.cardPaddings()) { + ListItem( + modifier = Modifier.clickable { onShowPasses() }, + leadingContent = { Icon(Icons.Default.EventRepeat, contentDescription = null) }, + headlineContent = { + Text(stringResource(R.string.passes_title), + style = MaterialTheme.typography.titleMedium) + } + ) + } + } + } + } + + else -> {} + } +} + +@Composable +fun LegacySectionHeader( + label: @Composable () -> Unit, +) { + Box(Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background)) { + Box(Modifier.cardPaddings()) { + ProvideTextStyle(MaterialTheme.typography.titleMedium + .copy(color = MaterialTheme.colorScheme.onBackground)) { + label() + } + } + } +} + +@Composable +fun LegacyBalanceRow( + balance: BalanceItem, + onClick: () -> Unit, + onPendingClick: (() -> Unit)? = null, +) { + OutlinedCard(Modifier.cardPaddings()) { + Column { + ListItem( + modifier = Modifier + .animateContentSize() + .clickable { onClick() } + .padding(vertical = 6.dp), + headlineContent = { + Text( + balance.available.toString(), + style = MaterialTheme.typography.displaySmall, + textDecoration = if (balance.scopeInfo is ExchangeLegacyKeys) { + TextDecoration.LineThrough + } else { + TextDecoration.None + }, + ) + }, + overlineContent = { + ProvideTextStyle(MaterialTheme.typography.bodySmall) { + 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) + ), + ) + + is ExchangeLegacyKeys -> Text( + stringResource( + R.string.balance_scope_exchange, + cleanExchange(balance.scopeInfo.url) + ), + ) + + else -> {} + } + } + }, + trailingContent = { + if (balance.scopeInfo is ExchangeLegacyKeys) { + LegacyWarningTooltip() + } + } + ) + + if (onPendingClick != null && + (!balance.pendingIncoming.isZero() || !balance.pendingOutgoing.isZero()) + ) { + HorizontalDivider() + LegacyPendingComposable(balance, onPendingClick) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LegacyWarningTooltip() { + val tooltipState = rememberTooltipState() + val coroutineScope = rememberCoroutineScope() + + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above, 4.dp), + tooltip = { PlainTooltip { Text(stringResource(R.string.balances_legacy_unavailable)) } }, + state = tooltipState, + ) { + Box( + modifier = Modifier + .clickable { coroutineScope.launch { tooltipState.show() } }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Warning, + tint = MaterialTheme.colorScheme.error, + contentDescription = stringResource(R.string.balances_legacy_unavailable), + ) + } + } +} + +@Composable +fun LegacyStatementRow( + summaryItem: DonauSummaryItem, + onClick: () -> Unit, +) { + OutlinedCard(Modifier.cardPaddings()) { + Column { + ListItem( + modifier = Modifier + .animateContentSize() + .clickable { onClick() } + .padding(vertical = 6.dp), + headlineContent = { + Text( + summaryItem.amountReceiptsAvailable.toString(), + style = MaterialTheme.typography.displaySmall, + ) + }, + overlineContent = { + ProvideTextStyle(value = MaterialTheme.typography.bodySmall) { + // FIXME: security risk, faking donauBaseUrl! + Text(stringResource(R.string.balance_scope_exchange, + summaryItem.legalDomain + ?: cleanExchange(summaryItem.donauBaseUrl))) + } + }, + trailingContent = { + Badge( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Text( + "${summaryItem.year}", + modifier = Modifier.padding(6.dp), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) + } + } +} + +@Composable +fun LegacyPendingComposable( + balance: BalanceItem, + onClick: () -> Unit, +) { + ListItem( + modifier = Modifier + .animateContentSize() + .clickable { onClick() }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + headlineContent = { + Column(modifier = Modifier.padding(vertical = 5.dp)) { + ProvideTextStyle(MaterialTheme.typography.bodyLarge) { + AnimatedVisibility(!balance.pendingIncoming.isZero()) { + Text( + stringResource( + R.string.balances_inbound_amount, + balance.pendingIncoming.toString(showSymbol = false), + ), + color = TalerTheme.extraColors.success, + ) + } + + AnimatedVisibility(!balance.pendingOutgoing.isZero()) { + Text( + stringResource( + R.string.balances_outbound_amount, + balance.pendingOutgoing.toString(showSymbol = false) + ), + color = MaterialTheme.colorScheme.error, + ) + } + } + } + }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + contentDescription = null, + ) + } + ) +} + +@Preview +@Composable +fun LegacyBalancesComposablePreview() { + 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"), + disablePeerPayments = false, + ), + 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"), + disablePeerPayments = false, + ), + 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"), + disablePeerPayments = false, + ), + BalanceItem( + scopeInfo = ExchangeLegacyKeys("KUDOS", "https://legacy.taler.net", masterPub = "123"), + available = Amount.fromJSONString("KUDOS:5.50"), + pendingIncoming = Amount.fromJSONString("KUDOS:1.40"), + pendingOutgoing = Amount.fromJSONString("KUDOS:0"), + disablePeerPayments = false, + ), + ) + + val donauSummary = listOf( + DonauSummaryItem( + donauBaseUrl = "https://donau.test.taler.net/", + legalDomain = "Gnuland", + year = 2025, + amountReceiptsSubmitted = Amount.fromJSONString("KUDOS:10"), + amountReceiptsAvailable = Amount.fromJSONString("KUDOS:10"), + ) + ) + + TalerSurface { + LegacyBalancesComposable( + innerPadding = PaddingValues(0.dp), + state = BalanceState.Success(balances, donauSummary), + devMode = false, + networkStatus = true, + onWithdrawMoneyClicked = {}, + onGetDemoMoneyClicked = {}, + onBalanceClicked = {}, + onStatementClicked = {}, + onPendingClicked = {}, + onShowDiscounts = {}, + onShowPasses = {}, + ) + } +} + +@Preview +@Composable +fun LegacyBalancesComposableErrorPreview() { + TalerSurface { + LegacyBalancesComposable ( + innerPadding = PaddingValues(0.dp), + state = BalanceState.Error(TalerErrorInfo + .makeCustomError("Balances could not be loaded")), + devMode = false, + networkStatus = false, + onWithdrawMoneyClicked = {}, + onGetDemoMoneyClicked = {}, + onBalanceClicked = {}, + onStatementClicked = {}, + ) + } +} + +@Preview +@Composable +fun LegacyBalancesComposableEmptyPreview() { + TalerSurface { + LegacyBalancesComposable ( + innerPadding = PaddingValues(0.dp), + state = BalanceState.Success(listOf(), listOf()), + devMode = false, + networkStatus = false, + onWithdrawMoneyClicked = {}, + onGetDemoMoneyClicked = {}, + onBalanceClicked = {}, + onStatementClicked = {}, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/main/LegacyMainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/LegacyMainScreen.kt @@ -0,0 +1,396 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.main + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.balances.BalanceState +import net.taler.wallet.balances.LegacyBalancesComposable +import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.scan.ScanTab +import net.taler.wallet.settings.SettingsContent +import net.taler.wallet.transactions.LegacyTransactionsComposable +import net.taler.wallet.transactions.TransactionStateFilter +import net.taler.wallet.transactions.TransactionsResult +import net.taler.wallet.transactions.onTransactionClicked + +private enum class LegacyMainTab { ASSETS, SETTINGS } + +/** + * Main screen as it looked before the paginated balances redesign. + * + * This is intentionally self-contained so it can be deleted together with the + * developer switch once [NewMainScreen] is ready to become the only main screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LegacyMainScreen( + model: MainViewModel, + onNavigate: NavigateCallback, + onFulfillPayment: (url: String) -> Unit, + onShowError: (TalerErrorInfo) -> Unit, +) { + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + var tab by rememberSaveable { mutableStateOf(LegacyMainTab.ASSETS) } + var showTransactions by rememberSaveable { mutableStateOf(false) } + var showSheet by rememberSaveable { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var stateFilter by remember { mutableStateOf<TransactionStateFilter?>(null) } + var selectionMode by remember { mutableStateOf(false) } + val selectedItems = remember { mutableStateListOf<String>() } + var showDeleteDialog by remember { mutableStateOf(false) } + + val context = LocalContext.current + val testWithdrawalMessage = stringResource(R.string.settings_test_withdrawal) + val networkStatus by model.networkManager.networkStatus.observeAsState(false) + val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) + val viewMode by model.viewMode.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState(false) + val actionButtonUsed by remember { + model.settingsManager.getActionButtonUsed(context) + }.collectAsStateLifecycleAware(true) + val txResult by remember(viewMode.selectedScope, stateFilter) { + model.transactionManager.transactionsFlow(viewMode.selectedScope, stateFilter = stateFilter) + }.collectAsStateLifecycleAware() + val selectedBalance = remember(balanceState, viewMode.selectedScope) { + (balanceState as? BalanceState.Success)?.balances?.find { + it.scopeInfo == viewMode.selectedScope + } + } + val disableActions = remember(balanceState, networkStatus) { + val hasSpendableBalance = (balanceState as? BalanceState.Success)?.balances + ?.any { it.scopeInfo !is ScopeInfo.ExchangeLegacyKeys } ?: false + !networkStatus || !hasSpendableBalance + } + + val showMessageEvent by model.showMessageEvent.observeAsState() + LaunchedEffect(showMessageEvent) { + showMessageEvent?.getIfNotConsumed()?.let { notify -> + val result = snackbarHostState.showSnackbar( + message = notify.content, + actionLabel = notify.actionLabel, + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + if (notify.actionLabel == "View") { + onNavigate(WalletDestination.Mailbox, false) + } + notify.onAction?.invoke() + } + } + } + + val title = when (tab) { + LegacyMainTab.ASSETS -> when { + showTransactions -> if (selectionMode) { + stringResource(R.string.selection_count, selectedItems.size) + } else stringResource(R.string.transactions_title) + + !balanceState.showWelcome() -> stringResource(R.string.balances_title) + else -> null + } + + LegacyMainTab.SETTINGS -> stringResource(R.string.menu_settings) + } + + if (showDeleteDialog) AlertDialog( + title = { Text(stringResource(R.string.transactions_delete_selected_dialog_title)) }, + text = { Text(stringResource(R.string.transactions_delete_selected_dialog_message)) }, + onDismissRequest = { showDeleteDialog = false }, + confirmButton = { + TextButton(onClick = { + model.transactionManager.deleteTransactions(selectedItems) { error -> + onShowError(error) + } + showDeleteDialog = false + selectionMode = false + selectedItems.clear() + }) { + Text(stringResource(R.string.transactions_delete)) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteDialog = false + }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + + BackHandler(selectionMode || (tab == LegacyMainTab.ASSETS && showTransactions)) { + if (selectionMode) { + selectionMode = false + selectedItems.clear() + } else { + showTransactions = false + model.showAssets() + } + } + + GlobalScaffold( + model = model, + modifier = Modifier.fillMaxSize(), + title = title?.let { { Text(it) } }, + navigationIcon = if (selectionMode) { + { + IconButton(onClick = { + selectionMode = false + selectedItems.clear() + }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null, + ) + } + } + } else null, + actions = { + if (selectionMode && txResult is TransactionsResult.Success) { + IconButton(onClick = { + selectedItems.clear() + val successResult = txResult as TransactionsResult.Success + selectedItems.addAll(successResult.transactions.map { it.transactionId }) + }) { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = stringResource(R.string.transactions_select_all), + ) + } + IconButton(onClick = { + showDeleteDialog = true + }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.transactions_delete), + ) + } + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.BarChart, contentDescription = null) }, + label = { Text(stringResource(R.string.balances_title)) }, + selected = tab == LegacyMainTab.ASSETS, + onClick = { + tab = LegacyMainTab.ASSETS + showTransactions = false + selectionMode = false + selectedItems.clear() + model.showAssets() + }, + ) + + TalerActionButton( + demandAttention = !actionButtonUsed, + onShowSheet = { showSheet = true }, + onScanQr = { + model.settingsManager.saveActionButtonUsed(context) + onNavigate(WalletDestination.ScanQr(ScanTab.SCAN_QR), true) + }, + ) + + NavigationBarItem( + icon = { Icon(Icons.Default.Settings, contentDescription = null) }, + label = { Text(stringResource(R.string.menu_settings)) }, + selected = tab == LegacyMainTab.SETTINGS, + onClick = { + tab = LegacyMainTab.SETTINGS + showTransactions = false + selectionMode = false + selectedItems.clear() + }, + ) + } + }, + ) { innerPadding -> + LaunchedEffect(Unit) { + val initialLoad = model.balanceManager.state.value is BalanceState.None + model.balanceManager.loadAssets(initialLoad) + } + + when (tab) { + LegacyMainTab.ASSETS -> if (showTransactions && selectedBalance != null) { + LegacyTransactionsComposable( + innerPadding = innerPadding, + balance = selectedBalance, + txResult = txResult, + stateFilter = stateFilter, + onTransactionClick = { tx -> + onTransactionClicked(tx, model, onNavigate) + }, + onShowBalancesClicked = { + showTransactions = false + model.showAssets() + }, + selectionMode = selectionMode, + selectedItems = selectedItems, + onToggleSelection = { txId -> + if (selectedItems.contains(txId)) { + selectedItems.remove(txId) + if (selectedItems.isEmpty()) selectionMode = false + } else { + selectionMode = true + selectedItems.add(txId) + } + }, + ) + } else { + LegacyBalancesComposable( + innerPadding = innerPadding, + state = balanceState, + devMode = devMode, + networkStatus = networkStatus, + onWithdrawMoneyClicked = { + onNavigate( + WalletDestination.HandleUri("taler://withdraw-exchange/exchange.taler-ops.ch/"), + true, + ) + }, + onGetDemoMoneyClicked = { + model.withdrawManager.withdrawTestBalance() + scope.launch { + snackbarHostState.showSnackbar(testWithdrawalMessage) + } + }, + onBalanceClicked = { balance -> + model.displayScope(balance.scopeInfo) + stateFilter = null + showTransactions = true + }, + onPendingClicked = { balance -> + model.displayScope(balance.scopeInfo) + stateFilter = TransactionStateFilter.Nonfinal + showTransactions = true + }, + onStatementClicked = { host -> + onNavigate(WalletDestination.DonauStatement(host), false) + }, + onShowDiscounts = { + onNavigate(WalletDestination.DiscountList, false) + }, + onShowPasses = { + onNavigate(WalletDestination.PassList, false) + }, + ) + } + + LegacyMainTab.SETTINGS -> SettingsContent( + model = model, + innerPadding = innerPadding, + snackbarHostState = snackbarHostState, + onNavigate = onNavigate, + onShowError = onShowError, + ) + } + + TalerActionsModal( + showSheet = showSheet, + sheetState = sheetState, + selectedCurrency = selectedBalance?.currency, + showShopping = selectedBalance?.shoppingUrls?.isNotEmpty() == true, + onDismiss = { + showSheet = false + }, + disableActions = disableActions, + disablePeer = selectedBalance?.disablePeerPayments == true, + onSend = { + showSheet = false + model.settingsManager.saveActionButtonUsed(context) + onNavigate(WalletDestination.OutgoingPush, true) + }, + onReceive = { + showSheet = false + model.settingsManager.saveActionButtonUsed(context) + onNavigate(WalletDestination.OutgoingPull, true) + }, + onScanQr = { + showSheet = false + model.settingsManager.saveActionButtonUsed(context) + onNavigate(WalletDestination.ScanQr(ScanTab.SCAN_QR), true) + }, + onDeposit = { + showSheet = false + model.settingsManager.saveActionButtonUsed(context) + onNavigate(WalletDestination.Deposit(), true) + }, + onWithdraw = { + showSheet = false + model.settingsManager.saveActionButtonUsed(context) + model.withdrawManager.resetWithdrawal() + onNavigate(WalletDestination.PromptWithdraw(), true) + }, + onEnterUri = { + showSheet = false + model.settingsManager.saveActionButtonUsed(context) + onNavigate(WalletDestination.ScanQr(ScanTab.ENTER_LINK), true) + }, + onShoppingDiscovery = { + showSheet = false + model.settingsManager.saveActionButtonUsed(context) + val shoppingUrls = selectedBalance?.shoppingUrls ?: emptyList() + if (shoppingUrls.size == 1) { + onFulfillPayment(shoppingUrls[0]) + } else onNavigate(WalletDestination.ExchangeShopping, false) + } + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt @@ -11,209 +11,50 @@ * 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/> + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. */ package net.taler.wallet.main -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.QrCodeScanner -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MediumFloatingActionButton -import androidx.compose.material3.PlainTooltip -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Text -import androidx.compose.material3.TooltipAnchorPosition -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch import net.taler.wallet.NavigateCallback -import net.taler.wallet.R -import net.taler.wallet.WalletDestination -import net.taler.wallet.balances.BalanceState -import net.taler.wallet.balances.BalancesPagerComposable -import net.taler.wallet.compose.GlobalScaffold -import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.scan.ScanTab -import net.taler.wallet.transactions.TransactionStateFilter -import net.taler.wallet.transactions.onTransactionClicked -import net.taler.wallet.ui.theme.onPrimaryLight -import net.taler.wallet.ui.theme.primaryLight +import net.taler.wallet.backend.TalerErrorInfo -@OptIn(ExperimentalMaterial3Api::class) +/** + * Temporary developer switch between the old and the new main screen. + * + * The old implementation is fully contained in [LegacyMainScreen] and its + * Legacy* composables. Once the new main screen is finished, delete + * [LegacyMainScreen], the legacy composables, the developer preference and this + * switch. + */ @Composable fun MainScreen( model: MainViewModel, onNavigate: NavigateCallback, onFulfillPayment: (url: String) -> Unit, + onShowError: (TalerErrorInfo) -> Unit, ) { - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - val testWithdrawalMessage = stringResource(R.string.settings_test_withdrawal) - val networkStatus by model.networkManager.networkStatus.observeAsState(false) - val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) - val viewMode by model.viewMode.collectAsStateLifecycleAware() - val devMode by model.devMode.observeAsState(false) - val txResults = remember(balanceState) { - (balanceState as? BalanceState.Success)?.let { state -> - state.balances.associate { balance -> - balance.scopeInfo to model.transactionManager.recentTransactionsFlow(balance.scopeInfo) - } - } ?: emptyMap() - } - - val showMessageEvent by model.showMessageEvent.observeAsState() - LaunchedEffect(showMessageEvent) { - showMessageEvent?.getIfNotConsumed()?.let { notify -> - val result = snackbarHostState.showSnackbar( - message = notify.content, - actionLabel = notify.actionLabel, - duration = SnackbarDuration.Short - ) - if (result == SnackbarResult.ActionPerformed) { - if (notify.actionLabel == "View") { - onNavigate(WalletDestination.Mailbox, false) - } - notify.onAction?.invoke() - } - } - } - - val tooltipState = rememberTooltipState() - var fabHeight by remember { mutableStateOf(0.dp) } - val density = LocalDensity.current - - GlobalScaffold( - model = model, - modifier = Modifier.fillMaxSize(), - title = {}, - snackbarHost = { SnackbarHost(snackbarHostState) }, - actions = { - IconButton(onClick = { - onNavigate(WalletDestination.Settings, false) - }) { - Icon( - Icons.Default.Settings, - contentDescription = stringResource(R.string.menu_settings)) - } - }, - floatingActionButton = { - TooltipBox( - positionProvider = TooltipDefaults - .rememberTooltipPositionProvider(TooltipAnchorPosition.Above), - tooltip = { - PlainTooltip { Text(stringResource(R.string.button_scan_qr_code_label)) } - }, - state = tooltipState, - ) { - MediumFloatingActionButton( - modifier = Modifier - .navigationBarsPadding() - .onGloballyPositioned { coordinates -> - fabHeight = with( density) { coordinates.size.height.toDp() } - }, - shape = CircleShape, - containerColor = primaryLight, - contentColor = onPrimaryLight, - onClick = { - onNavigate(WalletDestination.ScanQr(ScanTab.SCAN_QR), true) - }, - ) { - Icon( - Icons.Default.QrCodeScanner, - modifier = Modifier.size(34.dp), - contentDescription = stringResource(R.string.button_scan_qr_code_label), - ) - } - } - }, - ) { innerPadding -> - LaunchedEffect(Unit) { - val initialLoad = model.balanceManager.state.value is BalanceState.None - model.balanceManager.loadAssets(initialLoad) - } - - BalancesPagerComposable( - innerPadding = innerPadding, - bottomFabSkip = fabHeight + 24.dp, - state = balanceState, - selectedBalance = viewMode.selectedScope, - recentTxs = txResults, - devMode = devMode, - networkStatus = networkStatus, - onBalanceSelected = { - model.displayScope(it?.scopeInfo) - }, - onWithdrawMoney = { - onNavigate( - WalletDestination.HandleUri("taler://withdraw-exchange/exchange.taler-ops.ch/"), - true, - ) - }, - onGetDemoMoney = { - model.withdrawManager.withdrawTestBalance() - scope.launch { - snackbarHostState.showSnackbar(testWithdrawalMessage) - } - }, - onShopping = { balance -> - model.settingsManager.saveActionButtonUsed(context) - if (balance.shoppingUrls.size == 1) { - onFulfillPayment(balance.shoppingUrls[0]) - } else onNavigate(WalletDestination.ExchangeShopping, false) - }, - onSend = { onNavigate(WalletDestination.OutgoingPush, true) }, - onRequest = { onNavigate(WalletDestination.OutgoingPull, true) }, - onWithdraw = { - model.withdrawManager.resetWithdrawal() - onNavigate(WalletDestination.PromptWithdraw(), true) - }, - onDeposit = { onNavigate(WalletDestination.Deposit(), true) }, - onTransactionClicked = { tx -> - onTransactionClicked(tx, model, onNavigate) - }, - onShowAllTransactions = { scope -> - onNavigate(WalletDestination.Transactions(), false) - }, - onShowPendingIncoming = { - onNavigate(WalletDestination.Transactions(TransactionStateFilter.Nonfinal), false) - }, - onShowPendingOutgoing = { - onNavigate(WalletDestination.Transactions(TransactionStateFilter.Nonfinal), false) - }, - onShowDiscounts = { - onNavigate(WalletDestination.DiscountList, false) - }, - onShowPasses = { - onNavigate(WalletDestination.PassList, false) - }, + val newMainScreenEnabled by model.settingsManager + .getNewMainScreenEnabled(context) + .collectAsState(false) + + if (newMainScreenEnabled) { + NewMainScreen( + model = model, + onNavigate = onNavigate, + onFulfillPayment = onFulfillPayment, + ) + } else { + LegacyMainScreen( + model = model, + onNavigate = onNavigate, + onFulfillPayment = onFulfillPayment, + onShowError = onShowError, ) } } diff --git a/wallet/src/main/java/net/taler/wallet/main/NewMainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/NewMainScreen.kt @@ -0,0 +1,219 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.main + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MediumFloatingActionButton +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.balances.BalanceState +import net.taler.wallet.balances.BalancesPagerComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.scan.ScanTab +import net.taler.wallet.transactions.TransactionStateFilter +import net.taler.wallet.transactions.onTransactionClicked +import net.taler.wallet.ui.theme.onPrimaryLight +import net.taler.wallet.ui.theme.primaryLight + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewMainScreen( + model: MainViewModel, + onNavigate: NavigateCallback, + onFulfillPayment: (url: String) -> Unit, +) { + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + val context = LocalContext.current + val testWithdrawalMessage = stringResource(R.string.settings_test_withdrawal) + val networkStatus by model.networkManager.networkStatus.observeAsState(false) + val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) + val viewMode by model.viewMode.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState(false) + val txResults = remember(balanceState) { + (balanceState as? BalanceState.Success)?.let { state -> + state.balances.associate { balance -> + balance.scopeInfo to model.transactionManager.recentTransactionsFlow(balance.scopeInfo) + } + } ?: emptyMap() + } + + val showMessageEvent by model.showMessageEvent.observeAsState() + LaunchedEffect(showMessageEvent) { + showMessageEvent?.getIfNotConsumed()?.let { notify -> + val result = snackbarHostState.showSnackbar( + message = notify.content, + actionLabel = notify.actionLabel, + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + if (notify.actionLabel == "View") { + onNavigate(WalletDestination.Mailbox, false) + } + notify.onAction?.invoke() + } + } + } + + val tooltipState = rememberTooltipState() + var fabHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + GlobalScaffold( + model = model, + modifier = Modifier.fillMaxSize(), + title = {}, + snackbarHost = { SnackbarHost(snackbarHostState) }, + actions = { + IconButton(onClick = { + onNavigate(WalletDestination.Settings, false) + }) { + Icon( + Icons.Default.Settings, + contentDescription = stringResource(R.string.menu_settings)) + } + }, + floatingActionButton = { + TooltipBox( + positionProvider = TooltipDefaults + .rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + PlainTooltip { Text(stringResource(R.string.button_scan_qr_code_label)) } + }, + state = tooltipState, + ) { + MediumFloatingActionButton( + modifier = Modifier + .navigationBarsPadding() + .onGloballyPositioned { coordinates -> + fabHeight = with( density) { coordinates.size.height.toDp() } + }, + shape = CircleShape, + containerColor = primaryLight, + contentColor = onPrimaryLight, + onClick = { + onNavigate(WalletDestination.ScanQr(ScanTab.SCAN_QR), true) + }, + ) { + Icon( + Icons.Default.QrCodeScanner, + modifier = Modifier.size(34.dp), + contentDescription = stringResource(R.string.button_scan_qr_code_label), + ) + } + } + }, + ) { innerPadding -> + LaunchedEffect(Unit) { + val initialLoad = model.balanceManager.state.value is BalanceState.None + model.balanceManager.loadAssets(initialLoad) + } + + BalancesPagerComposable( + innerPadding = innerPadding, + bottomFabSkip = fabHeight + 24.dp, + state = balanceState, + selectedBalance = viewMode.selectedScope, + recentTxs = txResults, + devMode = devMode, + networkStatus = networkStatus, + onBalanceSelected = { + model.displayScope(it?.scopeInfo) + }, + onWithdrawMoney = { + onNavigate( + WalletDestination.HandleUri("taler://withdraw-exchange/exchange.taler-ops.ch/"), + true, + ) + }, + onGetDemoMoney = { + model.withdrawManager.withdrawTestBalance() + scope.launch { + snackbarHostState.showSnackbar(testWithdrawalMessage) + } + }, + onShopping = { balance -> + model.settingsManager.saveActionButtonUsed(context) + if (balance.shoppingUrls.size == 1) { + onFulfillPayment(balance.shoppingUrls[0]) + } else onNavigate(WalletDestination.ExchangeShopping, false) + }, + onSend = { onNavigate(WalletDestination.OutgoingPush, true) }, + onRequest = { onNavigate(WalletDestination.OutgoingPull, true) }, + onWithdraw = { + model.withdrawManager.resetWithdrawal() + onNavigate(WalletDestination.PromptWithdraw(), true) + }, + onDeposit = { onNavigate(WalletDestination.Deposit(), true) }, + onTransactionClicked = { tx -> + onTransactionClicked(tx, model, onNavigate) + }, + onShowAllTransactions = { scope -> + onNavigate(WalletDestination.Transactions(), false) + }, + onShowPendingIncoming = { + onNavigate(WalletDestination.Transactions(TransactionStateFilter.Nonfinal), false) + }, + onShowPendingOutgoing = { + onNavigate(WalletDestination.Transactions(TransactionStateFilter.Nonfinal), false) + }, + onShowDiscounts = { + onNavigate(WalletDestination.DiscountList, false) + }, + onShowPasses = { + onNavigate(WalletDestination.PassList, false) + }, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt b/wallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt @@ -0,0 +1,252 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.main + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LargeFloatingActionButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import net.taler.wallet.R +import net.taler.wallet.compose.DemandAttention +import net.taler.wallet.compose.GridMenu +import net.taler.wallet.compose.GridMenuItem +import net.taler.wallet.compose.Material3MenuGroup +import net.taler.wallet.compose.Material3MenuItemData +import net.taler.wallet.compose.TalerSurface +import kotlin.math.roundToInt + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun TalerActionButton( + demandAttention: Boolean, + onShowSheet: () -> Unit, + onScanQr: () -> Unit, +) { + val tooltipState = rememberTooltipState() + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(stringResource(R.string.actions)) } }, + state = tooltipState, + ) { + val offsetY = remember { Animatable(0f) } + var cancelled by remember { mutableStateOf(false) } + + DemandAttention(demandAttention = demandAttention) { + LargeFloatingActionButton( + modifier = Modifier + .requiredSize(86.dp) + .padding(8.dp) + .offset { IntOffset(0, offsetY.value.roundToInt() / 6) } + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + runBlocking { offsetY.snapTo(offsetY.value + delta) } + if (delta > 0) { + cancelled = true + } + }, + onDragStopped = { + offsetY.animateTo(0.0f) + if (!cancelled) { + onScanQr() + } + cancelled = false + }, + ), + shape = CircleShape, + onClick = { onShowSheet() }, + ) { + if (offsetY.value == 0.0f) { + Icon( + painterResource(R.drawable.ic_actions), + modifier = Modifier.size(38.dp), + contentDescription = stringResource(R.string.actions), + ) + } else { + Icon( + painterResource(R.drawable.ic_scan_qr), + contentDescription = stringResource(R.string.actions), + ) + } + } + } + } + + LaunchedEffect(demandAttention) { + if (demandAttention) { + tooltipState.show() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TalerActionsModal( + showSheet: Boolean, + sheetState: SheetState, + selectedCurrency: String? = null, + showShopping: Boolean, + disableActions: Boolean, + disablePeer: Boolean, + onDismiss: () -> Unit, + onSend: () -> Unit, + onReceive: () -> Unit, + onScanQr: () -> Unit, + onDeposit: () -> Unit, + onWithdraw: () -> Unit, + onEnterUri: () -> Unit, + onShoppingDiscovery: () -> Unit, +) { + if (showSheet) ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column { + if (showShopping && selectedCurrency != null) { + Box(Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 9.dp) + ) { + Material3MenuGroup(items = buildList { + add( + Material3MenuItemData( + title = { Text(stringResource(R.string.exchange_shopping_label, selectedCurrency)) }, + icon = { Icon( + Icons.Default.LocationOn, + contentDescription = null + ) }, + onClick = onShoppingDiscovery, + ) + ) + }) + } + } + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets + .systemBars + .asPaddingValues() + .calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.ic_link, + title = R.string.enter_uri, + onClick = { onEnterUri(); onDismiss() }, + ) + + GridMenuItem( + icon = R.drawable.transaction_deposit, + title = R.string.send_deposit_button_label, + onClick = { onDeposit(); onDismiss() }, + enabled = !disableActions + ) + + GridMenuItem( + icon = R.drawable.ic_scan_qr, + title = R.string.button_scan_qr_code_label, + onClick = { onScanQr(); onDismiss() }, + ) + + GridMenuItem( + icon = R.drawable.transaction_p2p_incoming, + title = R.string.transactions_receive_funds, + onClick = { onReceive(); onDismiss() }, + enabled = !disableActions && !disablePeer, + ) + + GridMenuItem( + icon = R.drawable.transaction_withdrawal, + title = R.string.withdraw_button_label, + onClick = { onWithdraw(); onDismiss() }, + enabled = !disableActions, + ) + + GridMenuItem( + icon = R.drawable.transaction_p2p_outgoing, + title = R.string.transactions_send_funds, + onClick = { onSend(); onDismiss() }, + enabled = !disableActions && !disablePeer, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun TalerActionsModalPreview() { + TalerSurface { + TalerActionsModal( + showSheet = true, + sheetState = rememberModalBottomSheetState(), + selectedCurrency = "CHF", + showShopping = true, + disableActions = false, + disablePeer = false, + onDismiss = {}, + onSend = {}, + onReceive = {}, + onScanQr = {}, + onDeposit = {}, + onWithdraw = {}, + onEnterUri = {}, + onShoppingDiscovery = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt @@ -75,6 +75,14 @@ class SettingsManager( } } + fun getActionButtonUsed(c: Context) = c.userPreferencesDataStore.data.map { prefs -> + if (prefs.hasActionButtonUsed()) { + prefs.actionButtonUsed + } else { + false + } + } + fun saveActionButtonUsed(c: Context) = scope.launch { c.userPreferencesDataStore.updateData { current -> current.toBuilder() @@ -99,6 +107,22 @@ class SettingsManager( } } + fun getNewMainScreenEnabled(c: Context) = c.userPreferencesDataStore.data.map { prefs -> + if (prefs.hasNewMainScreenEnabled()) { + prefs.newMainScreenEnabled + } else { + false + } + } + + fun setNewMainScreenEnabled(c: Context, enabled: Boolean) = scope.launch { + c.userPreferencesDataStore.updateData { current -> + current.toBuilder() + .setNewMainScreenEnabled(enabled) + .build() + } + } + fun getBiometricLockEnabled(c: Context) = c.userPreferencesDataStore.data.map { prefs -> if (prefs.hasBiometricLockEnabled()) { prefs.biometricLockEnabled diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt @@ -38,6 +38,7 @@ import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -53,6 +54,7 @@ import androidx.compose.material.icons.filled.LocalAtm import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.material.icons.filled.ViewCarousel import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api @@ -99,8 +101,34 @@ fun SettingsScreen( onNavigateBack: () -> Unit, onShowError: (TalerErrorInfo) -> Unit, ) { - val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + + GlobalScaffold( + model = model, + modifier = Modifier.fillMaxSize(), + title = { Text(stringResource(R.string.menu_settings)) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + onNavigateBack = onNavigateBack, + ) { innerPadding -> + SettingsContent( + model = model, + innerPadding = innerPadding, + snackbarHostState = snackbarHostState, + onNavigate = onNavigate, + onShowError = onShowError, + ) + } +} + +@Composable +fun SettingsContent( + model: MainViewModel, + innerPadding: PaddingValues, + snackbarHostState: SnackbarHostState, + onNavigate: NavigateCallback, + onShowError: (TalerErrorInfo) -> Unit, +) { + val context = LocalContext.current val dbExportMessage = stringResource(R.string.settings_db_export_message) val dbImportMessage = stringResource(R.string.settings_db_import_message) val testWithdrawalMessage = stringResource(R.string.settings_test_withdrawal) @@ -115,6 +143,7 @@ fun SettingsScreen( val biometricLockEnabled by settingsManager.getBiometricLockEnabled(context).collectAsState(false) val devModeEnabled by settingsManager.getDevModeEnabled(context).collectAsState(false) + val newMainScreenEnabled by settingsManager.getNewMainScreenEnabled(context).collectAsState(false) val withdrawTestStatus by withdrawManager.withdrawTestStatus.collectAsState() val databaseBackend by model.databaseBackend.collectAsState() @@ -150,256 +179,260 @@ fun SettingsScreen( } } - GlobalScaffold( - model = model, - modifier = Modifier.fillMaxSize(), - title = { Text(stringResource(R.string.menu_settings)) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - onNavigateBack = onNavigateBack, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .verticalScroll(rememberScrollState()) - ) { - SettingsItem( - title = stringResource(R.string.exchange_settings_title), - summary = stringResource(R.string.exchange_settings_summary), - icon = Icons.Default.Dns, - onClick = { onNavigate(WalletDestination.ExchangeList, false) } - ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { + SettingsItem( + title = stringResource(R.string.exchange_settings_title), + summary = stringResource(R.string.exchange_settings_summary), + icon = Icons.Default.Dns, + onClick = { onNavigate(WalletDestination.ExchangeList, false) } + ) - SettingsItem( - title = stringResource(R.string.settings_bank_accounts), - summary = stringResource(R.string.settings_bank_accounts_summary), - icon = Icons.Default.AccountBalance, - onClick = { onNavigate(WalletDestination.BankAccounts(), false) } - ) + SettingsItem( + title = stringResource(R.string.settings_bank_accounts), + summary = stringResource(R.string.settings_bank_accounts_summary), + icon = Icons.Default.AccountBalance, + onClick = { onNavigate(WalletDestination.BankAccounts(), false) } + ) - SettingsItem( - title = stringResource(R.string.settings_donau), - summary = stringResource(R.string.settings_donau_summary), - icon = ImageVector.vectorResource(R.drawable.ic_donau), - onClick = { onNavigate(WalletDestination.SetDonau(), false) } - ) + SettingsItem( + title = stringResource(R.string.settings_donau), + summary = stringResource(R.string.settings_donau_summary), + icon = ImageVector.vectorResource(R.drawable.ic_donau), + onClick = { onNavigate(WalletDestination.SetDonau(), false) } + ) - SettingsSwitchItem( - title = stringResource(R.string.settings_lock_auth), - summary = stringResource(R.string.settings_lock_auth_summary), - icon = ImageVector.vectorResource(R.drawable.ic_shield), - checked = biometricLockEnabled, - onCheckedChange = { enabled -> - if (enabled) { - enableBiometrics( - context, - settingsManager, - true, - biometricAuthUnavailableMessage - ) { - biometricEnrollLauncher.launch(it) - } - } else { - settingsManager.setBiometricLockEnabled(context, false) + SettingsSwitchItem( + title = stringResource(R.string.settings_lock_auth), + summary = stringResource(R.string.settings_lock_auth_summary), + icon = ImageVector.vectorResource(R.drawable.ic_shield), + checked = biometricLockEnabled, + onCheckedChange = { enabled -> + if (enabled) { + enableBiometrics( + context, + settingsManager, + true, + biometricAuthUnavailableMessage + ) { + biometricEnrollLauncher.launch(it) } + } else { + settingsManager.setBiometricLockEnabled(context, false) } - ) + } + ) + + SettingsSwitchItem( + title = stringResource(R.string.settings_dev_mode), + summary = stringResource(R.string.settings_dev_mode_summary), + icon = ImageVector.vectorResource(R.drawable.ic_developer_mode), + checked = devModeEnabled, + onCheckedChange = { enabled -> + settingsManager.setDevModeEnabled(context, enabled) + } + ) + + if (devModeEnabled) { + HorizontalDivider() SettingsSwitchItem( - title = stringResource(R.string.settings_dev_mode), - summary = stringResource(R.string.settings_dev_mode_summary), - icon = ImageVector.vectorResource(R.drawable.ic_developer_mode), - checked = devModeEnabled, + title = stringResource(R.string.settings_new_main_screen), + summary = stringResource(R.string.settings_new_main_screen_summary), + icon = Icons.Default.ViewCarousel, + checked = newMainScreenEnabled, onCheckedChange = { enabled -> - settingsManager.setDevModeEnabled(context, enabled) + settingsManager.setNewMainScreenEnabled(context, enabled) } ) - if (devModeEnabled) { - HorizontalDivider() + HorizontalDivider() - SettingsItem( - title = stringResource(R.string.exchange_list_add_dev), - icon = Icons.Default.DomainAdd, - onClick = { - model.exchangeManager.addDevExchanges() - onNavigate(WalletDestination.ExchangeList, false) - } - ) + SettingsItem( + title = stringResource(R.string.exchange_list_add_dev), + icon = Icons.Default.DomainAdd, + onClick = { + model.exchangeManager.addDevExchanges() + onNavigate(WalletDestination.ExchangeList, false) + } + ) - SettingsItem( - title = stringResource(R.string.settings_withdraw_testkudos), - summary = stringResource(R.string.settings_withdraw_testkudos_summary), - icon = Icons.Default.LocalAtm, - enabled = withdrawTestStatus !is TestWithdrawStatus.Withdrawing, - onClick = { - withdrawManager.withdrawTestBalance() - scope.launch { snackbarHostState.showSnackbar(testWithdrawalMessage) } - onNavigate(WalletDestination.Main, true) - } - ) + SettingsItem( + title = stringResource(R.string.settings_withdraw_testkudos), + summary = stringResource(R.string.settings_withdraw_testkudos_summary), + icon = Icons.Default.LocalAtm, + enabled = withdrawTestStatus !is TestWithdrawStatus.Withdrawing, + onClick = { + withdrawManager.withdrawTestBalance() + scope.launch { snackbarHostState.showSnackbar(testWithdrawalMessage) } + onNavigate(WalletDestination.Main, true) + } + ) - SettingsItem( - title = stringResource(R.string.settings_logcat), - summary = stringResource(R.string.settings_logcat_summary), - icon = ImageVector.vectorResource(R.drawable.ic_bug_report), - onClick = { logLauncher.launch("taler-wallet-log-${System.currentTimeMillis()}.txt") } - ) + SettingsItem( + title = stringResource(R.string.settings_logcat), + summary = stringResource(R.string.settings_logcat_summary), + icon = ImageVector.vectorResource(R.drawable.ic_bug_report), + onClick = { logLauncher.launch("taler-wallet-log-${System.currentTimeMillis()}.txt") } + ) - SettingsItem( - title = stringResource(R.string.settings_stats), - summary = stringResource(R.string.settings_stats_summary), - icon = ImageVector.vectorResource(R.drawable.ic_stats), - onClick = { onNavigate(WalletDestination.PerformanceStats, false) } - ) + SettingsItem( + title = stringResource(R.string.settings_stats), + summary = stringResource(R.string.settings_stats_summary), + icon = ImageVector.vectorResource(R.drawable.ic_stats), + onClick = { onNavigate(WalletDestination.PerformanceStats, false) } + ) - SettingsItem( - title = "Contacts", - summary = "Contacts", - icon = Icons.Default.Person, - onClick = { onNavigate(WalletDestination.Contacts(), false) } - ) + SettingsItem( + title = "Contacts", + summary = "Contacts", + icon = Icons.Default.Person, + onClick = { onNavigate(WalletDestination.Contacts(), false) } + ) - SettingsItem( - title = "Mailbox", - summary = "P2P Messaging", - icon = ImageVector.vectorResource(R.drawable.ic_adjust), - onClick = { onNavigate(WalletDestination.Mailbox, false) } - ) + SettingsItem( + title = "Mailbox", + summary = "P2P Messaging", + icon = ImageVector.vectorResource(R.drawable.ic_adjust), + onClick = { onNavigate(WalletDestination.Mailbox, false) } + ) - SettingsItem( - title = stringResource(R.string.alias_registration_title), - summary = "Taldir Registration", - icon = Icons.Default.Person, - onClick = { onNavigate(WalletDestination.AliasRegistration, false) } - ) + SettingsItem( + title = stringResource(R.string.alias_registration_title), + summary = "Taldir Registration", + icon = Icons.Default.Person, + onClick = { onNavigate(WalletDestination.AliasRegistration, false) } + ) - SettingsItem( - title = stringResource(R.string.settings_db_export), - summary = stringResource(R.string.settings_db_export_summary), - icon = ImageVector.vectorResource(R.drawable.ic_unarchive), - onClick = { pendingDbExport = DbExportType.Json } - ) + SettingsItem( + title = stringResource(R.string.settings_db_export), + summary = stringResource(R.string.settings_db_export_summary), + icon = ImageVector.vectorResource(R.drawable.ic_unarchive), + onClick = { pendingDbExport = DbExportType.Json } + ) - SettingsItem( - title = stringResource(R.string.settings_db_export_raw), - summary = stringResource(R.string.settings_db_export_raw_summary), - icon = ImageVector.vectorResource(R.drawable.ic_database), - onClick = { pendingDbExport = DbExportType.Raw } - ) + SettingsItem( + title = stringResource(R.string.settings_db_export_raw), + summary = stringResource(R.string.settings_db_export_raw_summary), + icon = ImageVector.vectorResource(R.drawable.ic_database), + onClick = { pendingDbExport = DbExportType.Raw } + ) - SettingsItem( - title = stringResource(R.string.settings_db_import), - summary = stringResource(R.string.settings_db_import_summary), - icon = ImageVector.vectorResource(R.drawable.ic_archive), - onClick = { - MaterialAlertDialogBuilder(context) - .setMessage(R.string.settings_dialog_import_message) - .setNegativeButton(R.string.import_db) { _, _ -> - dbImportLauncher.launch(arrayOf("application/json")) - } - .setPositiveButton(R.string.cancel) { _, _ -> - scope.launch { snackbarHostState.showSnackbar(importCanceledMessage) } - } - .show() - } - ) + SettingsItem( + title = stringResource(R.string.settings_db_import), + summary = stringResource(R.string.settings_db_import_summary), + icon = ImageVector.vectorResource(R.drawable.ic_archive), + onClick = { + MaterialAlertDialogBuilder(context) + .setMessage(R.string.settings_dialog_import_message) + .setNegativeButton(R.string.import_db) { _, _ -> + dbImportLauncher.launch(arrayOf("application/json")) + } + .setPositiveButton(R.string.cancel) { _, _ -> + scope.launch { snackbarHostState.showSnackbar(importCanceledMessage) } + } + .show() + } + ) - if (databaseBackend == WalletDatabaseBackend.IndexedDB) SettingsItem( - title = stringResource(R.string.settings_migrate_db), - summary = stringResource(R.string.settings_migrate_db_summary), - icon = Icons.Default.Memory, - onClick = model::offerDatabaseMigration, - ) + if (databaseBackend == WalletDatabaseBackend.IndexedDB) SettingsItem( + title = stringResource(R.string.settings_migrate_db), + summary = stringResource(R.string.settings_migrate_db_summary), + icon = Icons.Default.Memory, + onClick = model::offerDatabaseMigration, + ) + + SettingsItem( + title = stringResource(R.string.settings_version_app), + summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR} ${BuildConfig.VERSION_CODE})", + icon = ImageVector.vectorResource(R.drawable.ic_account_balance_wallet), + ) + + SettingsItem( + title = stringResource(R.string.settings_version_core), + summary = "$walletVersion ($walletVersionHash)", + icon = ImageVector.vectorResource(R.drawable.ic_adjust), + ) + if (exchangeVersion != null) { SettingsItem( - title = stringResource(R.string.settings_version_app), - summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR} ${BuildConfig.VERSION_CODE})", - icon = ImageVector.vectorResource(R.drawable.ic_account_balance_wallet), + title = stringResource(R.string.settings_version_protocol_exchange), + summary = exchangeVersion, + icon = ImageVector.vectorResource(R.drawable.ic_account_balance), ) + } + if (merchantVersion != null) { SettingsItem( - title = stringResource(R.string.settings_version_core), - summary = "$walletVersion ($walletVersionHash)", - icon = ImageVector.vectorResource(R.drawable.ic_adjust), + title = stringResource(R.string.settings_version_protocol_merchant), + summary = merchantVersion, + icon = ImageVector.vectorResource(R.drawable.ic_store_mall), ) + } - if (exchangeVersion != null) { - SettingsItem( - title = stringResource(R.string.settings_version_protocol_exchange), - summary = exchangeVersion, - icon = ImageVector.vectorResource(R.drawable.ic_account_balance), - ) + SettingsItem( + title = stringResource(R.string.settings_test), + summary = stringResource(R.string.settings_test_summary), + icon = Icons.Default.VideoLibrary, + onClick = { + settingsManager.runIntegrationTest { onShowError(it) } + scope.launch { snackbarHostState.showSnackbar(testRunningMessage) } + onNavigate(WalletDestination.Main, true) } + ) - if (merchantVersion != null) { - SettingsItem( - title = stringResource(R.string.settings_version_protocol_merchant), - summary = merchantVersion, - icon = ImageVector.vectorResource(R.drawable.ic_store_mall), - ) + SettingsItem( + title = stringResource(R.string.settings_reset), + summary = stringResource(R.string.settings_reset_summary), + icon = ImageVector.vectorResource(R.drawable.ic_nuke), + onClick = { + MaterialAlertDialogBuilder(context) + .setMessage(R.string.settings_dialog_reset_message) + .setNegativeButton(R.string.reset) { _, _ -> + settingsManager.clearDb { + model.dangerouslyReset() + } + scope.launch { snackbarHostState.showSnackbar(resetDoneMessage) } + } + .setPositiveButton(R.string.cancel) { _, _ -> + scope.launch { snackbarHostState.showSnackbar(resetCanceledMessage) } + } + .show() } + ) - SettingsItem( - title = stringResource(R.string.settings_test), - summary = stringResource(R.string.settings_test_summary), - icon = Icons.Default.VideoLibrary, - onClick = { - settingsManager.runIntegrationTest { onShowError(it) } - scope.launch { snackbarHostState.showSnackbar(testRunningMessage) } - onNavigate(WalletDestination.Main, true) - } - ) - - SettingsItem( - title = stringResource(R.string.settings_reset), - summary = stringResource(R.string.settings_reset_summary), - icon = ImageVector.vectorResource(R.drawable.ic_nuke), - onClick = { - MaterialAlertDialogBuilder(context) - .setMessage(R.string.settings_dialog_reset_message) - .setNegativeButton(R.string.reset) { _, _ -> - settingsManager.clearDb { - model.dangerouslyReset() - } - scope.launch { snackbarHostState.showSnackbar(resetDoneMessage) } + pendingDbExport?.let { exportType -> + AlertDialog( + onDismissRequest = { pendingDbExport = null }, + title = { Text(stringResource(R.string.wallet_export_database)) }, + text = { Text(stringResource(R.string.wallet_export_database_warning)) }, + confirmButton = { + Button(onClick = { + pendingDbExport = null + when (exportType) { + DbExportType.Json -> + dbExportLauncher.launch("taler-wallet-db-${System.currentTimeMillis()}.json") + DbExportType.Raw -> + rawDbExportLauncher.launch("taler-wallet-db-${System.currentTimeMillis()}.sqlite3") } - .setPositiveButton(R.string.cancel) { _, _ -> - scope.launch { snackbarHostState.showSnackbar(resetCanceledMessage) } - } - .show() + }) { + Text(stringResource(R.string.wallet_export_database_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { pendingDbExport = null }) { + Text(stringResource(R.string.cancel)) + } } ) - - pendingDbExport?.let { exportType -> - AlertDialog( - onDismissRequest = { pendingDbExport = null }, - title = { Text(stringResource(R.string.wallet_export_database)) }, - text = { Text(stringResource(R.string.wallet_export_database_warning)) }, - confirmButton = { - Button(onClick = { - pendingDbExport = null - when (exportType) { - DbExportType.Json -> - dbExportLauncher.launch("taler-wallet-db-${System.currentTimeMillis()}.json") - DbExportType.Raw -> - rawDbExportLauncher.launch("taler-wallet-db-${System.currentTimeMillis()}.sqlite3") - } - }) { - Text(stringResource(R.string.wallet_export_database_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { pendingDbExport = null }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } } - - BottomInsetsSpacer() } + + BottomInsetsSpacer() } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/LegacyTransactionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/LegacyTransactionsComposable.kt @@ -0,0 +1,589 @@ +/* + * 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.basicMarquee +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.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +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.height +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.Badge +import androidx.compose.material3.HorizontalDivider +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +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.lib.android.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.LegacyWarningTooltip +import net.taler.wallet.balances.ScopeInfo.Exchange +import net.taler.wallet.balances.ScopeInfo.ExchangeLegacyKeys +import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.Banner +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.cardPaddings +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.Dialog +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.BalanceKycRequired +import net.taler.wallet.transactions.TransactionMinorState.BankConfirmTransfer +import net.taler.wallet.transactions.TransactionMinorState.KycAuthRequired +import net.taler.wallet.transactions.TransactionMinorState.KycInit +import net.taler.wallet.transactions.TransactionMinorState.KycRequired +import net.taler.wallet.transactions.TransactionMinorState.Repurchase +import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal +import net.taler.wallet.transactions.TransactionsResult.Error +import net.taler.wallet.transactions.TransactionsResult.None +import net.taler.wallet.transactions.TransactionsResult.Success +import net.taler.wallet.ui.theme.TalerTheme + +@Composable +fun LegacyTransactionsComposable( + innerPadding: PaddingValues, + stateFilter: TransactionStateFilter?, + balance: BalanceItem, + txResult: TransactionsResult, + onTransactionClick: (tx: Transaction) -> Unit, + onShowBalancesClicked: () -> Unit, + selectionMode: Boolean, + selectedItems: MutableList<String>, + onToggleSelection: (String) -> Unit, +) { + Column(Modifier.fillMaxSize()) { + LazyColumn( + Modifier + .weight(1f) + .consumeWindowInsets(innerPadding), + contentPadding = innerPadding, + ) { + item { + LegacyTransactionsHeader( + balance = balance, + onShowBalancesClicked = onShowBalancesClicked, + ) + } + + when (stateFilter) { + Nonfinal -> item { + Banner(Modifier.padding(bottom = 6.dp)) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.transactions_filter_nonfinal), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } + } + else -> {} + } + + val placeholderPadding = PaddingValues(vertical = 50.dp) + when (txResult) { + is Success -> if (txResult.transactions.isEmpty()) item { + Box(Modifier.padding(placeholderPadding)) { + LegacyEmptyTransactionsComposable() + } + } else { + items(txResult.transactions, key = { it.transactionId }) { tx -> + val isSelected = selectedItems.contains(tx.transactionId) + + LegacyTransactionRow( + tx, balance.available.spec, + isSelected = isSelected, + selectionMode = selectionMode, + onTransactionClick = { + if (selectionMode) { + onToggleSelection(tx.transactionId) + } else { + onTransactionClick(tx) + } + }, + onTransactionSelect = { + onToggleSelection(tx.transactionId) + }, + ) + } + } + + is None -> item { + Box(Modifier.padding(placeholderPadding)) { + LoadingScreen() + } + } + is Error -> item { + Box(Modifier.padding(placeholderPadding)) { + LegacyErrorTransactionsComposable(txResult.error) + } + } + } + } + } +} + +@Composable +fun LegacyEmptyTransactionsComposable() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Center, + ) { + Text( + stringResource(R.string.transactions_empty), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +fun LegacyErrorTransactionsComposable(error: TalerErrorInfo) { + Text( + text = stringResource(R.string.transactions_error, error.userFacingMsg), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.error, + ) +} + +@Composable +fun LegacyTransactionsHeader( + balance: BalanceItem, + onShowBalancesClicked: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedCard( + Modifier + .weight(1f) + .cardPaddings() + .clickable { onShowBalancesClicked() }, + ) { + ListItem( + modifier = Modifier.animateContentSize(), + headlineContent = { + Text( + getHeaderCurrency(balance, balance.available.spec), + modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, + supportingContent = { + if (balance.scopeInfo is Exchange) { + Text( + cleanExchange(balance.scopeInfo.url), + modifier = Modifier + .padding(top = 3.dp) + .basicMarquee(), + style = MaterialTheme.typography.bodySmall, + ) + } else if (balance.scopeInfo is ExchangeLegacyKeys) { + Text( + cleanExchange(balance.scopeInfo.url), + modifier = Modifier + .padding(top = 3.dp) + .basicMarquee(), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (balance.scopeInfo is ExchangeLegacyKeys) { + LegacyWarningTooltip() + } + 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.toString(showSymbol = false), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textDecoration = if (balance.scopeInfo is ExchangeLegacyKeys) { + TextDecoration.LineThrough + } else { + TextDecoration.None + }, + ) + } + } +} + +@Composable +fun LegacyTransactionRow( + tx: Transaction, + spec: CurrencySpecification?, + isSelected: Boolean, + selectionMode: Boolean, + onTransactionClick: () -> Unit, + onTransactionSelect: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + + Column { + ListItem( + modifier = Modifier + .height(IntrinsicSize.Min) + .defaultMinSize(minHeight = 80.dp) + .combinedClickable( + onClick = onTransactionClick, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onTransactionSelect() + }, + ), + trailingContent = { + Box( + modifier = Modifier + .fillMaxHeight() + .padding(8.dp), + contentAlignment = Center, + ) { + LegacyTransactionAmountInfo(tx, spec) + } + }, + leadingContent = { + Box( + modifier = Modifier + .fillMaxHeight() + .padding(8.dp), + contentAlignment = Center, + ) { + 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 = { + Column(Modifier.padding(vertical = 8.dp)) { + Text( + text = tx.timestamp.ms.toRelativeTime(LocalContext.current).toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + tx.getTitle(), + modifier = Modifier.padding(vertical = 3.dp), + style = MaterialTheme.typography.titleMedium, + ) + ProvideTextStyle( + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + LegacyTransactionExtraInfo(tx) + } + } + }, + colors = ListItemDefaults.colors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + ListItemDefaults.containerColor + } + ) + ) + + HorizontalDivider() + } +} + +@Composable +fun LegacyTransactionAmountInfo( + tx: Transaction, + spec: CurrencySpecification?, +) { + Column(horizontalAlignment = Alignment.End) { + ProvideTextStyle(MaterialTheme.typography.titleLarge) { + val amountStr = tx.amountEffective.withSpec(spec).toString(showSymbol = false) + if (tx.amountEffective.isZero()) { + Text(amountStr) + } else when (tx.amountType) { + Positive -> Text( + stringResource(R.string.amount_positive, amountStr), + color = if (tx.txState.major == Pending || tx.txState.major == Dialog) + Color.Unspecified else TalerTheme.extraColors.success, + ) + Negative -> Text( + stringResource(R.string.amount_negative, amountStr), + color = if (tx.txState.major == Pending || tx.txState.major == Dialog) + Color.Unspecified else MaterialTheme.colorScheme.error, + ) + Neutral -> Text(amountStr) + } + } + + if (tx.txState.major == Pending || tx.txState.major == Dialog) { + Badge( + Modifier.padding(top = 3.dp), + containerColor = MaterialTheme.colorScheme.secondary, + ) { + Text(stringResource(R.string.transaction_pending)) + } + } + } +} + +@Composable +fun LegacyTransactionExtraInfo(tx: Transaction) { + when { + tx.txState.major == Aborted -> Text( + stringResource(R.string.payment_aborted), + color = MaterialTheme.colorScheme.error, + ) + + tx.txState.minor == Repurchase -> Text( + stringResource(R.string.payment_repurchase), + ) + + 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)) + KycInit -> Text(stringResource(R.string.transaction_preparing_kyc)) + KycRequired, + KycAuthRequired, + BalanceKycRequired -> Text(stringResource(R.string.transactions_required_kyc)) + else -> {} + } + + tx.txState.major == Dialog -> {} + + 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"), + disablePeerPayments = false, +) + +@Preview +@Composable +fun LegacyTransactionsComposableDonePreview() { + 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), + scopes = listOf(Exchange( + currency = "TESTKUDOS", + url = "exchange.test.taler.net", + )) + ) + + val transactions = listOf(t) + + TalerSurface { + LegacyTransactionsComposable( + innerPadding = PaddingValues(0.dp), + balance = previewBalance, + stateFilter = null, + txResult = Success(transactions), + onTransactionClick = {}, + onShowBalancesClicked = {}, + selectionMode = false, + selectedItems = mutableListOf(), + onToggleSelection = {}, + ) + } +} + +@Preview +@Composable +fun LegacyTransactionsComposablePendingPreview() { + 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), + scopes = listOf(Exchange( + currency = "TESTKUDOS", + url = "exchange.test.taler.net", + )) + ) + + val transactions = listOf(t) + + TalerSurface { + LegacyTransactionsComposable( + innerPadding = PaddingValues(0.dp), + balance = previewBalance, + stateFilter = Nonfinal, + txResult = Success(transactions), + onTransactionClick = {}, + onShowBalancesClicked = {}, + selectionMode = false, + selectedItems = mutableListOf(), + onToggleSelection = {}, + ) + } +} + +@Preview +@Composable +fun LegacyTransactionsComposableEmptyPreview() { + TalerSurface { + LegacyTransactionsComposable( + innerPadding = PaddingValues(0.dp), + balance = previewBalance, + stateFilter = null, + txResult = Success(listOf()), + onTransactionClick = {}, + onShowBalancesClicked = {}, + selectionMode = false, + selectedItems = mutableListOf(), + onToggleSelection = {}, + ) + } +} + +@Preview +@Composable +fun LegacyTransactionsComposableLoadingPreview() { + TalerSurface { + LegacyTransactionsComposable( + innerPadding = PaddingValues(0.dp), + balance = previewBalance, + stateFilter = null, + txResult = None, + onTransactionClick = {}, + onShowBalancesClicked = {}, + selectionMode = false, + selectedItems = mutableListOf(), + onToggleSelection = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/proto/user_prefs.proto b/wallet/src/main/proto/user_prefs.proto @@ -51,4 +51,6 @@ message UserPreferences { optional bool biometricLockEnabled = 4; optional PrefsViewMode viewMode = 5; optional PrefsViewMode2 viewMode2 = 6; + // Temporary developer switch between the legacy and the new main screen. + optional bool newMainScreenEnabled = 7; } \ No newline at end of file diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -558,6 +558,8 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="settings_db_migrate_complete">SQLite database migration complete</string> <string name="settings_dev_mode">Developer mode</string> <string name="settings_dev_mode_summary">Shows more information intended for debugging</string> + <string name="settings_new_main_screen">New main screen</string> + <string name="settings_new_main_screen_summary">Use the experimental paginated balances main screen (temporary)</string> <string name="settings_dialog_import_message">This operation will overwrite your existing database. Do you want to continue?</string> <string name="settings_dialog_reset_message">Do you really want to reset the wallet and lose all coins and purchases?</string> <string name="settings_donau">Tax-deductible donations</string>